Compare commits

...

12 Commits
v5.5.9 ... main

Author SHA1 Message Date
螃蟹
adbf793e79 fix(@vben/web-ele): the main color tone for switching between dark and light themes has been reset (#6678) 2025-08-25 11:03:54 +08:00
LinaBell
cf6c4c9aae fix: cannot read properties of null (reading 'nextSibling') (#6667) 2025-08-21 22:26:10 +08:00
Ken Hai
ffaf85c8f3 fix: 修复角色修改时VbenTree组件没有回显选中 (#6662)
* fix: 修复角色修改时VbenTree组件没有回显选中

* chore: use nextTick

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: merge

* chore: 更新

---------

Co-authored-by: haiyinlong <haiyinlong@uhigame.com>
Co-authored-by: Jin Mao <50581550+jinmao88@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-21 15:30:58 +08:00
panda7
2cc78f925f fix: the bug in the lock method of the vbenModal component (#6648) 2025-08-21 15:17:55 +08:00
ming4762
93f0eea4e7 fix: fix the issue of excessive line spacing in vbenForm (#6653)
* gap-2和 pb-4/2 重叠导致间距过宽,gap-x只保留列间距
2025-08-21 12:41:04 +08:00
谦元吉
58e3941810 chore(docs): update the component import of the form adapter description in the document (#6656) 2025-08-19 16:48:10 +08:00
Svend
3ad433a50b fix: 修复在 hash 路由模式下无法在新窗口打开路由的问题 (#6652)
此问题是由于 PR #6583 中新增的 `resolveHref` 函数导致的。其在 hash 路由模式下,得到的 URL 会包含 #/ 前缀。在经过 openRouteInNewWindow 的逻辑后就会出现两次 /# 前缀
2025-08-19 16:47:45 +08:00
ming4762
8ac2db5b7c fix: fix the issue of VbenForm compact reactive failure (#6654) 2025-08-19 16:46:14 +08:00
Elm1992
a441dcebae fix: meta.link invalid issue 2025-08-19 16:40:16 +08:00
Vben
ff4704d5ea chore: Upgrade vite to version 7.x (#6645) 2025-08-16 22:50:31 +08:00
菠萝吹雪
6ddfbd84b0 chore: modify the contributor showcase in the README (#6636) 2025-08-16 22:47:08 +08:00
ming4762
1e6417f95b feat: vBenForm add layout: inline (#6644) 2025-08-16 22:41:08 +08:00
20 changed files with 756 additions and 287 deletions

View File

@@ -140,8 +140,12 @@ pnpm build
## 貢献者 ## 貢献者
<a href="https://openomy.app/github/vbenjs/vue-vben-admin" target="_blank" style="display: block; width: 100%;" align="center">
<img src="https://openomy.app/svg?repo=vbenjs/vue-vben-admin&chart=bubble&latestMonth=3" target="_blank" alt="Contribution Leaderboard" style="display: block; width: 100%;" />
</a>
<a href="https://github.com/vbenjs/vue-vben-admin/graphs/contributors"> <a href="https://github.com/vbenjs/vue-vben-admin/graphs/contributors">
<img alt="Contributors" src="https://opencollective.com/vbenjs/contributors.svg?button=false" /> <img alt="Contributors" src="https://contrib.rocks/image?repo=vbenjs/vue-vben-admin" />
</a> </a>
## Discord ## Discord

View File

@@ -140,8 +140,12 @@ If you think this project is helpful to you, you can help the author buy a cup o
## Contributors ## Contributors
<a href="https://openomy.app/github/vbenjs/vue-vben-admin" target="_blank" style="display: block; width: 100%;" align="center">
<img src="https://openomy.app/svg?repo=vbenjs/vue-vben-admin&chart=bubble&latestMonth=3" target="_blank" alt="Contribution Leaderboard" style="display: block; width: 100%;" />
</a>
<a href="https://github.com/vbenjs/vue-vben-admin/graphs/contributors"> <a href="https://github.com/vbenjs/vue-vben-admin/graphs/contributors">
<img alt="Contributors" src="https://opencollective.com/vbenjs/contributors.svg?button=false" /> <img alt="Contributors" src="https://contrib.rocks/image?repo=vbenjs/vue-vben-admin" />
</a> </a>
## Discord ## Discord

View File

@@ -140,8 +140,12 @@ pnpm build
## 贡献者 ## 贡献者
<a href="https://openomy.app/github/vbenjs/vue-vben-admin" target="_blank" style="display: block; width: 100%;" align="center">
<img src="https://openomy.app/svg?repo=vbenjs/vue-vben-admin&chart=bubble&latestMonth=3" target="_blank" alt="Contribution Leaderboard" style="display: block; width: 100%;" />
</a>
<a href="https://github.com/vbenjs/vue-vben-admin/graphs/contributors"> <a href="https://github.com/vbenjs/vue-vben-admin/graphs/contributors">
<img alt="Contributors" src="https://opencollective.com/vbenjs/contributors.svg?button=false" /> <img alt="Contributors" src="https://contrib.rocks/image?repo=vbenjs/vue-vben-admin" />
</a> </a>
## Discord ## Discord

View File

@@ -90,30 +90,52 @@ import { h } from 'vue';
import { globalShareState, IconPicker } from '@vben/common-ui'; import { globalShareState, IconPicker } from '@vben/common-ui';
import { $t } from '@vben/locales'; import { $t } from '@vben/locales';
import { const AutoComplete = defineAsyncComponent(
AutoComplete, () => import('ant-design-vue/es/auto-complete'),
Button, );
Checkbox, const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
CheckboxGroup, const Checkbox = defineAsyncComponent(
DatePicker, () => import('ant-design-vue/es/checkbox'),
Divider, );
Input, const CheckboxGroup = defineAsyncComponent(() =>
InputNumber, import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup),
InputPassword, );
Mentions, const DatePicker = defineAsyncComponent(
notification, () => import('ant-design-vue/es/date-picker'),
Radio, );
RadioGroup, const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider'));
RangePicker, const Input = defineAsyncComponent(() => import('ant-design-vue/es/input'));
Rate, const InputNumber = defineAsyncComponent(
Select, () => import('ant-design-vue/es/input-number'),
Space, );
Switch, const InputPassword = defineAsyncComponent(() =>
Textarea, import('ant-design-vue/es/input').then((res) => res.InputPassword),
TimePicker, );
TreeSelect, const Mentions = defineAsyncComponent(
Upload, () => import('ant-design-vue/es/mentions'),
} from 'ant-design-vue'; );
const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio'));
const RadioGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/radio').then((res) => res.RadioGroup),
);
const RangePicker = defineAsyncComponent(() =>
import('ant-design-vue/es/date-picker').then((res) => res.RangePicker),
);
const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate'));
const Select = defineAsyncComponent(() => import('ant-design-vue/es/select'));
const Space = defineAsyncComponent(() => import('ant-design-vue/es/space'));
const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch'));
const Textarea = defineAsyncComponent(() =>
import('ant-design-vue/es/input').then((res) => res.Textarea),
);
const TimePicker = defineAsyncComponent(
() => import('ant-design-vue/es/time-picker'),
);
const TreeSelect = defineAsyncComponent(
() => import('ant-design-vue/es/tree-select'),
);
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
const withDefaultPlaceholder = <T extends Component>( const withDefaultPlaceholder = <T extends Component>(
component: T, component: T,
@@ -304,7 +326,7 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
| 属性名 | 描述 | 类型 | 默认值 | | 属性名 | 描述 | 类型 | 默认值 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| layout | 表单项布局 | `'horizontal' \| 'vertical'` | `horizontal` | | layout | 表单项布局 | `'horizontal' \| 'vertical'\| 'inline'` | `horizontal` |
| showCollapseButton | 是否显示折叠按钮 | `boolean` | `false` | | showCollapseButton | 是否显示折叠按钮 | `boolean` | `false` |
| wrapperClass | 表单的布局基于tailwindcss | `any` | - | | wrapperClass | 表单的布局基于tailwindcss | `any` | - |
| actionWrapperClass | 表单操作区域class | `any` | - | | actionWrapperClass | 表单操作区域class | `any` | - |

View File

@@ -98,7 +98,7 @@
"node": ">=20.10.0", "node": ">=20.10.0",
"pnpm": ">=9.12.0" "pnpm": ">=9.12.0"
}, },
"packageManager": "pnpm@10.12.4", "packageManager": "pnpm@10.14.0",
"pnpm": { "pnpm": {
"peerDependencyRules": { "peerDependencyRules": {
"allowedVersions": { "allowedVersions": {

View File

@@ -30,7 +30,7 @@ function openWindow(url: string, options: OpenWindowOptions = {}): void {
function openRouteInNewWindow(path: string) { function openRouteInNewWindow(path: string) {
const { hash, origin } = location; const { hash, origin } = location;
const fullPath = path.startsWith('/') ? path : `/${path}`; const fullPath = path.startsWith('/') ? path : `/${path}`;
const url = `${origin}${hash ? '/#' : ''}${fullPath}`; const url = `${origin}${hash && !fullPath.startsWith('/#') ? '/#' : ''}${fullPath}`;
openWindow(url, { target: '_blank' }); openWindow(url, { target: '_blank' });
} }

View File

@@ -82,11 +82,11 @@ const actionWrapperClass = computed(() => {
const cls = [ const cls = [
'flex', 'flex',
'w-full',
'items-center', 'items-center',
'gap-3', 'gap-3',
props.compact ? 'pb-2' : 'pb-4', props.compact ? 'pb-2' : 'pb-4',
props.layout === 'vertical' ? 'self-end' : 'self-center', props.layout === 'vertical' ? 'self-end' : 'self-center',
props.layout === 'inline' ? '' : 'w-full',
props.actionWrapperClass, props.actionWrapperClass,
]; ];

View File

@@ -59,7 +59,7 @@ const values = useFormValues();
const errors = useFieldError(fieldName); const errors = useFieldError(fieldName);
const fieldComponentRef = useTemplateRef<HTMLInputElement>('fieldComponentRef'); const fieldComponentRef = useTemplateRef<HTMLInputElement>('fieldComponentRef');
const formApi = formRenderProps.form; const formApi = formRenderProps.form;
const compact = formRenderProps.compact; const compact = computed(() => formRenderProps.compact);
const isInValid = computed(() => errors.value?.length > 0); const isInValid = computed(() => errors.value?.length > 0);
const FieldComponent = computed(() => { const FieldComponent = computed(() => {

View File

@@ -42,11 +42,11 @@ const emits = defineEmits<{
}>(); }>();
const wrapperClass = computed(() => { const wrapperClass = computed(() => {
const cls = ['flex flex-col']; const cls = ['flex'];
if (props.layout === 'vertical') { if (props.layout === 'inline') {
cls.push(props.compact ? 'gap-x-2' : 'gap-x-4'); cls.push('flex-wrap gap-x-2');
} else { } else {
cls.push('gap-2'); cls.push(props.compact ? 'gap-x-2' : 'gap-x-4', 'flex-col grid');
} }
return cn(...cls, props.wrapperClass); return cn(...cls, props.wrapperClass);
}); });
@@ -170,7 +170,7 @@ const computedSchema = computed(
<template> <template>
<component :is="formComponent" v-bind="formComponentProps"> <component :is="formComponent" v-bind="formComponentProps">
<div ref="wrapperRef" :class="wrapperClass" class="grid"> <div ref="wrapperRef" :class="wrapperClass">
<template v-for="cSchema in computedSchema" :key="cSchema.fieldName"> <template v-for="cSchema in computedSchema" :key="cSchema.fieldName">
<!-- <div v-if="$slots[cSchema.fieldName]" :class="cSchema.formItemClass"> <!-- <div v-if="$slots[cSchema.fieldName]" :class="cSchema.formItemClass">
<slot :definition="cSchema" :name="cSchema.fieldName"> </slot> <slot :definition="cSchema" :name="cSchema.fieldName"> </slot>

View File

@@ -8,7 +8,7 @@ import type { ClassType, MaybeComputedRef } from '@vben-core/typings';
import type { FormApi } from './form-api'; import type { FormApi } from './form-api';
export type FormLayout = 'horizontal' | 'vertical'; export type FormLayout = 'horizontal' | 'inline' | 'vertical';
export type BaseFormComponentType = export type BaseFormComponentType =
| 'DefaultButton' | 'DefaultButton'

View File

@@ -107,7 +107,6 @@ export class ModalApi {
this.store.setState((prev) => ({ this.store.setState((prev) => ({
...prev, ...prev,
isOpen: false, isOpen: false,
submitting: false,
})); }));
} }
} }
@@ -162,7 +161,11 @@ export class ModalApi {
} }
open() { open() {
this.store.setState((prev) => ({ ...prev, isOpen: true })); this.store.setState((prev) => ({
...prev,
isOpen: true,
submitting: false,
}));
} }
setData<T>(payload: T) { setData<T>(payload: T) {

View File

@@ -8,12 +8,7 @@ import { computed, ref } from 'vue';
import { cn } from '@vben-core/shared/utils'; import { cn } from '@vben-core/shared/utils';
import { X } from 'lucide-vue-next'; import { X } from 'lucide-vue-next';
import { import { DialogClose, DialogContent, useForwardPropsEmits } from 'radix-vue';
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from 'radix-vue';
import DialogOverlay from './DialogOverlay.vue'; import DialogOverlay from './DialogOverlay.vue';
@@ -87,7 +82,7 @@ defineExpose({
</script> </script>
<template> <template>
<DialogPortal :to="appendTo"> <Teleport :to="appendTo">
<Transition name="fade"> <Transition name="fade">
<DialogOverlay <DialogOverlay
v-if="open && modal" v-if="open && modal"
@@ -132,5 +127,5 @@ defineExpose({
<X class="h-4 w-4" /> <X class="h-4 w-4" />
</DialogClose> </DialogClose>
</DialogContent> </DialogContent>
</DialogPortal> </Teleport>
</template> </template>

View File

@@ -7,7 +7,7 @@ import { computed, ref } from 'vue';
import { cn } from '@vben-core/shared/utils'; import { cn } from '@vben-core/shared/utils';
import { DialogContent, DialogPortal, useForwardPropsEmits } from 'radix-vue'; import { DialogContent, useForwardPropsEmits } from 'radix-vue';
import { sheetVariants } from './sheet'; import { sheetVariants } from './sheet';
import SheetOverlay from './SheetOverlay.vue'; import SheetOverlay from './SheetOverlay.vue';
@@ -73,7 +73,7 @@ function onAnimationEnd(event: AnimationEvent) {
</script> </script>
<template> <template>
<DialogPortal :to="appendTo"> <Teleport :to="appendTo">
<Transition name="fade"> <Transition name="fade">
<SheetOverlay <SheetOverlay
v-if="open && modal" v-if="open && modal"
@@ -103,5 +103,5 @@ function onAnimationEnd(event: AnimationEvent) {
<Cross2Icon class="h-5 w-" /> <Cross2Icon class="h-5 w-" />
</DialogClose> --> </DialogClose> -->
</DialogContent> </DialogContent>
</DialogPortal> </Teleport>
</template> </template>

View File

@@ -29,7 +29,8 @@ function useNavigation() {
return true; return true;
} }
const route = routeMetaMap.get(path); const route = routeMetaMap.get(path);
return route?.meta?.openInNewWindow ?? false; // 如果有外链或者设置了在新窗口打开,返回 true
return !!(route?.meta?.link || route?.meta?.openInNewWindow);
}; };
const resolveHref = (path: string): string => { const resolveHref = (path: string): string => {
@@ -39,7 +40,13 @@ function useNavigation() {
const navigation = async (path: string) => { const navigation = async (path: string) => {
try { try {
const route = routeMetaMap.get(path); const route = routeMetaMap.get(path);
const { openInNewWindow = false, query = {} } = route?.meta ?? {}; const { openInNewWindow = false, query = {}, link } = route?.meta ?? {};
// 检查是否有外链
if (link && typeof link === 'string') {
openWindow(link, { target: '_blank' });
return;
}
if (isHttpUrl(path)) { if (isHttpUrl(path)) {
openWindow(path, { target: '_blank' }); openWindow(path, { target: '_blank' });

View File

@@ -104,7 +104,7 @@ function selectColor() {
watch( watch(
() => [modelValue.value, props.isDark] as [BuiltinThemeType, boolean], () => [modelValue.value, props.isDark] as [BuiltinThemeType, boolean],
([themeType, isDark]) => { ([themeType, isDark], [_, isDarkPrev]) => {
const theme = builtinThemePresets.value.find( const theme = builtinThemePresets.value.find(
(item) => item.type === themeType, (item) => item.type === themeType,
); );
@@ -113,7 +113,9 @@ watch(
? theme.darkPrimaryColor || theme.primaryColor ? theme.darkPrimaryColor || theme.primaryColor
: theme.primaryColor; : theme.primaryColor;
themeColorPrimary.value = primaryColor || theme.color; if (!(theme.type === 'custom' && isDark !== isDarkPrev)) {
themeColorPrimary.value = primaryColor || theme.color;
}
} }
}, },
); );

View File

@@ -86,6 +86,62 @@ const [QueryForm] = useVbenForm({
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
}); });
const [InlineForm] = useVbenForm({
layout: 'inline',
schema: [
{
// 组件需要在 #/adapter.ts内注册并加上类型
component: 'Input',
// 对应组件的参数
componentProps: {
placeholder: '请输入用户名',
},
// 字段名
fieldName: 'username',
// 界面显示的label
label: '字符串',
},
{
component: 'InputPassword',
componentProps: {
placeholder: '请输入密码',
},
fieldName: 'password',
label: '密码',
},
{
component: 'InputNumber',
componentProps: {
placeholder: '请输入',
},
fieldName: 'number',
label: '数字(带后缀)',
suffix: () => '¥',
},
{
component: 'Select',
componentProps: {
allowClear: true,
filterOption: true,
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
],
placeholder: '请选择',
showSearch: true,
},
fieldName: 'options',
label: '下拉选',
},
],
});
const [QueryForm1] = useVbenForm({ const [QueryForm1] = useVbenForm({
// 默认展开 // 默认展开
collapsed: true, collapsed: true,
@@ -205,6 +261,10 @@ function onSubmit(values: Record<string, any>) {
<QueryForm /> <QueryForm />
</Card> </Card>
<Card class="mb-5" title="查询表单,单行表单">
<InlineForm />
</Card>
<Card class="mb-5" title="查询表单,默认展开,垂直布局"> <Card class="mb-5" title="查询表单,默认展开,垂直布局">
<QueryForm2 /> <QueryForm2 />
</Card> </Card>

View File

@@ -5,7 +5,7 @@ import type { Recordable } from '@vben/types';
import type { SystemRoleApi } from '#/api/system/role'; import type { SystemRoleApi } from '#/api/system/role';
import { computed, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import { useVbenDrawer, VbenTree } from '@vben/common-ui'; import { useVbenDrawer, VbenTree } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
@@ -47,20 +47,26 @@ const [Drawer, drawerApi] = useVbenDrawer({
drawerApi.unlock(); drawerApi.unlock();
}); });
}, },
onOpenChange(isOpen) {
async onOpenChange(isOpen) {
if (isOpen) { if (isOpen) {
const data = drawerApi.getData<SystemRoleApi.SystemRole>(); const data = drawerApi.getData<SystemRoleApi.SystemRole>();
formApi.resetForm(); formApi.resetForm();
if (data) { if (data) {
formData.value = data; formData.value = data;
id.value = data.id; id.value = data.id;
formApi.setValues(data);
} else { } else {
id.value = undefined; id.value = undefined;
} }
if (permissions.value.length === 0) { if (permissions.value.length === 0) {
loadPermissions(); await loadPermissions();
}
// Wait for Vue to flush DOM updates (form fields mounted)
await nextTick();
if (data) {
formApi.setValues(data);
} }
} }
}, },

685
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -58,8 +58,8 @@ catalog:
'@typescript-eslint/parser': ^8.35.1 '@typescript-eslint/parser': ^8.35.1
'@vee-validate/zod': ^4.15.1 '@vee-validate/zod': ^4.15.1
'@vite-pwa/vitepress': ^1.0.0 '@vite-pwa/vitepress': ^1.0.0
'@vitejs/plugin-vue': ^5.2.4 '@vitejs/plugin-vue': ^6.0.1
'@vitejs/plugin-vue-jsx': ^4.2.0 '@vitejs/plugin-vue-jsx': ^5.0.1
'@vue/reactivity': ^3.5.17 '@vue/reactivity': ^3.5.17
'@vue/shared': ^3.5.17 '@vue/shared': ^3.5.17
'@vue/test-utils': ^2.4.6 '@vue/test-utils': ^2.4.6
@@ -167,10 +167,10 @@ catalog:
tippy.js: ^6.3.7 tippy.js: ^6.3.7
turbo: ^2.5.4 turbo: ^2.5.4
typescript: ^5.8.3 typescript: ^5.8.3
unbuild: ^3.5.0 unbuild: ^3.6.1
unplugin-element-plus: ^0.10.0 unplugin-element-plus: ^0.10.0
vee-validate: ^4.15.1 vee-validate: ^4.15.1
vite: ^6.3.5 vite: ^7.1.2
vite-plugin-compression: ^0.5.1 vite-plugin-compression: ^0.5.1
vite-plugin-dts: ^4.5.4 vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2 vite-plugin-html: ^3.2.2

View File

@@ -3,30 +3,104 @@ import { join, normalize } from 'node:path';
const rootDir = process.cwd(); const rootDir = process.cwd();
// 控制并发数量,避免创建过多的并发任务
const CONCURRENCY_LIMIT = 10;
// 需要跳过的目录,避免进入这些目录进行清理
const SKIP_DIRS = new Set(['.DS_Store', '.git', '.idea', '.vscode']);
/** /**
* 递归查找并删除目标目录 * 处理单个文件/目录
* @param {string} currentDir - 当前目录路径
* @param {string} item - 文件/目录名
* @param {string[]} targets - 要删除的目标列表
* @param {number} _depth - 当前递归深度
* @returns {Promise<boolean>} - 是否需要进一步递归处理
*/
async function processItem(currentDir, item, targets, _depth) {
// 跳过特殊目录
if (SKIP_DIRS.has(item)) {
return false;
}
try {
const itemPath = normalize(join(currentDir, item));
if (targets.includes(item)) {
// 匹配到目标目录或文件时直接删除
await fs.rm(itemPath, { force: true, recursive: true });
console.log(`✅ Deleted: ${itemPath}`);
return false; // 已删除,无需递归
}
// 使用 readdir 的 withFileTypes 选项,避免额外的 lstat 调用
return true; // 可能需要递归,由调用方决定
} catch (error) {
// 更详细的错误信息
if (error.code === 'ENOENT') {
// 文件不存在,可能已被删除,这是正常情况
return false;
} else if (error.code === 'EPERM' || error.code === 'EACCES') {
console.error(`❌ Permission denied: ${item} in ${currentDir}`);
} else {
console.error(
`❌ Error handling item ${item} in ${currentDir}: ${error.message}`,
);
}
return false;
}
}
/**
* 递归查找并删除目标目录(并发优化版本)
* @param {string} currentDir - 当前遍历的目录路径 * @param {string} currentDir - 当前遍历的目录路径
* @param {string[]} targets - 要删除的目标列表 * @param {string[]} targets - 要删除的目标列表
* @param {number} depth - 当前递归深度,避免过深递归
*/ */
async function cleanTargetsRecursively(currentDir, targets) { async function cleanTargetsRecursively(currentDir, targets, depth = 0) {
const items = await fs.readdir(currentDir); // 限制递归深度,避免无限递归
if (depth > 10) {
console.warn(`Max recursion depth reached at: ${currentDir}`);
return;
}
for (const item of items) { let dirents;
try { try {
const itemPath = normalize(join(currentDir, item)); // 使用 withFileTypes 选项,一次性获取文件类型信息,避免后续 lstat 调用
const stat = await fs.lstat(itemPath); dirents = await fs.readdir(currentDir, { withFileTypes: true });
} catch (error) {
// 如果无法读取目录,可能已被删除或权限不足
console.warn(`Cannot read directory ${currentDir}: ${error.message}`);
return;
}
if (targets.includes(item)) { // 分批处理,控制并发数量
// 匹配到目标目录或文件时直接删除 for (let i = 0; i < dirents.length; i += CONCURRENCY_LIMIT) {
await fs.rm(itemPath, { force: true, recursive: true }); const batch = dirents.slice(i, i + CONCURRENCY_LIMIT);
console.log(`Deleted: ${itemPath}`);
} else if (stat.isDirectory()) { const tasks = batch.map(async (dirent) => {
// 只对目录进行递归处理 const item = dirent.name;
await cleanTargetsRecursively(itemPath, targets); const shouldRecurse = await processItem(currentDir, item, targets, depth);
// 如果是目录且没有被删除,则递归处理
if (shouldRecurse && dirent.isDirectory()) {
const itemPath = normalize(join(currentDir, item));
return cleanTargetsRecursively(itemPath, targets, depth + 1);
} }
} catch (error) {
console.error( return null;
`Error handling item ${item} in ${currentDir}: ${error.message}`, });
// 并发执行当前批次的任务
const results = await Promise.allSettled(tasks);
// 检查是否有失败的任务(可选:用于调试)
const failedTasks = results.filter(
(result) => result.status === 'rejected',
);
if (failedTasks.length > 0) {
console.warn(
`${failedTasks.length} tasks failed in batch starting at index ${i} in directory: ${currentDir}`,
); );
} }
} }
@@ -43,14 +117,25 @@ async function cleanTargetsRecursively(currentDir, targets) {
} }
console.log( console.log(
`Starting cleanup of targets: ${cleanupTargets.join(', ')} from root: ${rootDir}`, `🚀 Starting cleanup of targets: ${cleanupTargets.join(', ')} from root: ${rootDir}`,
); );
const startTime = Date.now();
try { try {
// 先统计要删除的目标数量
console.log('📊 Scanning for cleanup targets...');
await cleanTargetsRecursively(rootDir, cleanupTargets); await cleanTargetsRecursively(rootDir, cleanupTargets);
console.log('Cleanup process completed successfully.');
const endTime = Date.now();
const duration = (endTime - startTime) / 1000;
console.log(
`✨ Cleanup process completed successfully in ${duration.toFixed(2)}s`,
);
} catch (error) { } catch (error) {
console.error(`Unexpected error during cleanup: ${error.message}`); console.error(`💥 Unexpected error during cleanup: ${error.message}`);
process.exit(1); process.exit(1);
} }
})(); })();