Browse Source

Merge branch 'vbenjs:main' into main

pull/8367/head
失忆 4 months ago
committed by GitHub
parent
commit
f3ace124f6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      .github/workflows/deploy.yml
  2. 2
      .gitignore
  3. 12
      .npmrc
  4. 2
      .vscode/settings.json
  5. 85
      apps/backend-mock/api/system/user/list.ts
  6. 8
      apps/web-antd/src/adapter/component/index.ts
  7. 23
      apps/web-antd/src/layouts/basic.vue
  8. 8
      apps/web-antdv-next/src/adapter/component/index.ts
  9. 23
      apps/web-antdv-next/src/layouts/basic.vue
  10. 8
      apps/web-ele/src/adapter/component/index.ts
  11. 23
      apps/web-ele/src/layouts/basic.vue
  12. 8
      apps/web-naive/src/adapter/component/index.ts
  13. 23
      apps/web-naive/src/layouts/basic.vue
  14. 8
      apps/web-tdesign/src/adapter/component/index.ts
  15. 23
      apps/web-tdesign/src/layouts/basic.vue
  16. 2
      docs/.vitepress/build/dayjs-loader.mjs
  17. 10
      docs/.vitepress/build/dayjs-resolve-hook.mjs
  18. 8
      docs/.vitepress/config/en.mts
  19. 4
      docs/.vitepress/config/index.mts
  20. 23
      docs/.vitepress/config/shared.mts
  21. 18
      docs/.vitepress/config/zh.mts
  22. 2
      docs/.vitepress/theme/components/site-layout.vue
  23. 5
      docs/package.json
  24. 11
      docs/src/_env/adapter/component.ts
  25. 2
      docs/src/_env/adapter/vxe-table.ts
  26. 2
      docs/src/components/common-ui/vben-api-component.md
  27. 172
      docs/src/components/common-ui/vben-cropper.md
  28. 2
      docs/src/components/common-ui/vben-form.md
  29. 199
      docs/src/components/common-ui/vben-tiptap.md
  30. 2
      docs/src/components/common-ui/vben-vxe-table.md
  31. 2
      docs/src/demos/vben-alert/alert/index.vue
  32. 2
      docs/src/demos/vben-alert/confirm/index.vue
  33. 2
      docs/src/demos/vben-alert/prompt/index.vue
  34. 2
      docs/src/demos/vben-api-component/cascader/index.vue
  35. 102
      docs/src/demos/vben-cropper/aspect-ratio/index.vue
  36. 70
      docs/src/demos/vben-cropper/basic/index.vue
  37. 2
      docs/src/demos/vben-form/api/index.vue
  38. 2
      docs/src/demos/vben-form/basic/index.vue
  39. 2
      docs/src/demos/vben-form/custom/index.vue
  40. 2
      docs/src/demos/vben-form/dynamic/index.vue
  41. 2
      docs/src/demos/vben-form/query/index.vue
  42. 2
      docs/src/demos/vben-form/rules/index.vue
  43. 2
      docs/src/demos/vben-form/value-format/index.vue
  44. 19
      docs/src/demos/vben-tiptap/basic/index.vue
  45. 45
      docs/src/demos/vben-tiptap/image-upload/index.vue
  46. 2
      docs/src/demos/vben-vxe-table/basic/index.vue
  47. 2
      docs/src/demos/vben-vxe-table/custom-cell/index.vue
  48. 2
      docs/src/demos/vben-vxe-table/edit-row/index.vue
  49. 2
      docs/src/demos/vben-vxe-table/fixed/index.vue
  50. 2
      docs/src/demos/vben-vxe-table/form/index.vue
  51. 2
      docs/src/demos/vben-vxe-table/remote/index.vue
  52. 2
      docs/src/demos/vben-vxe-table/tree/index.vue
  53. 2
      docs/src/en/components/common-ui/vben-api-component.md
  54. 159
      docs/src/en/components/common-ui/vben-cropper.md
  55. 2
      docs/src/en/components/common-ui/vben-form.md
  56. 199
      docs/src/en/components/common-ui/vben-tiptap.md
  57. 10
      docs/src/en/guide/essentials/external-module.md
  58. 2
      docs/src/en/guide/essentials/server.md
  59. 2
      docs/src/en/guide/other/faq.md
  60. 10
      docs/src/guide/essentials/external-module.md
  61. 2
      docs/src/guide/essentials/server.md
  62. 2
      docs/src/guide/in-depth/check-updates.md
  63. 2
      docs/src/guide/other/faq.md
  64. 1
      internal/lint-configs/oxlint-config/src/configs/comments.ts
  65. 70
      internal/vite-config/src/plugins/dayjs.ts
  66. 7
      internal/vite-config/src/plugins/index.ts
  67. 5
      internal/vite-config/src/typing.ts
  68. 4
      lefthook.yml
  69. 4
      package.json
  70. 418
      packages/@core/base/shared/src/cache/README.md
  71. 119
      packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts
  72. 4
      packages/@core/base/shared/src/cache/index.ts
  73. 137
      packages/@core/base/shared/src/cache/indexeddb-driver.ts
  74. 71
      packages/@core/base/shared/src/cache/local-storage-driver.ts
  75. 32
      packages/@core/base/shared/src/cache/memory-storage-driver.ts
  76. 140
      packages/@core/base/shared/src/cache/storage-manager.ts
  77. 46
      packages/@core/base/shared/src/cache/types.ts
  78. 4
      packages/@core/base/shared/src/utils/__tests__/state-handler.test.ts
  79. 6
      packages/@core/base/shared/src/utils/__tests__/util.test.ts
  80. 3
      packages/@core/base/shared/src/utils/date.ts
  81. 4
      packages/@core/base/shared/src/utils/state-handler.ts
  82. 10
      packages/@core/preferences/__tests__/preferences.test.ts
  83. 60
      packages/@core/preferences/src/preferences.ts
  84. 2
      packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts
  85. 2
      packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts
  86. 34
      packages/@core/ui-kit/shadcn-ui/src/components/context-menu/context-menu.vue
  87. 6
      packages/@core/ui-kit/shadcn-ui/src/ui/input/Input.vue
  88. 2
      packages/@core/ui-kit/shadcn-ui/src/ui/select/SelectTrigger.vue
  89. 72
      packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue
  90. 2
      packages/@core/ui-kit/shadcn-ui/src/ui/tree/types.ts
  91. 3
      packages/effects/common-ui/src/components/icon-picker/icon-picker.vue
  92. 12
      packages/effects/common-ui/src/components/page/page.vue
  93. 6
      packages/effects/common-ui/src/components/page/types.ts
  94. 2
      packages/effects/hooks/src/use-tabs.ts
  95. 12
      packages/effects/layouts/src/basic/layout.vue
  96. 15
      packages/effects/layouts/src/widgets/global-search/search-panel.vue
  97. 4
      packages/effects/layouts/src/widgets/preferences/blocks/layout/widget.vue
  98. 6
      packages/effects/layouts/src/widgets/preferences/blocks/theme/builtin.vue
  99. 8
      packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue
  100. 1
      packages/effects/plugins/package.json

10
.github/workflows/deploy.yml

@ -30,7 +30,7 @@ jobs:
run: pnpm build:play
- name: Sync Playground files
uses: SamKirkland/FTP-Deploy-Action@v4.3.6
uses: SamKirkland/FTP-Deploy-Action@v4.4.0
with:
server: ${{ secrets.PRO_FTP_HOST }}
username: ${{ secrets.WEB_PLAYGROUND_FTP_ACCOUNT }}
@ -54,7 +54,7 @@ jobs:
run: pnpm build:docs
- name: Sync Docs files
uses: SamKirkland/FTP-Deploy-Action@v4.3.6
uses: SamKirkland/FTP-Deploy-Action@v4.4.0
with:
server: ${{ secrets.PRO_FTP_HOST }}
username: ${{ secrets.WEBSITE_FTP_ACCOUNT }}
@ -85,7 +85,7 @@ jobs:
run: pnpm run build:antd
- name: Sync files
uses: SamKirkland/FTP-Deploy-Action@v4.3.6
uses: SamKirkland/FTP-Deploy-Action@v4.4.0
with:
server: ${{ secrets.PRO_FTP_HOST }}
username: ${{ secrets.WEB_ANTD_FTP_ACCOUNT }}
@ -116,7 +116,7 @@ jobs:
run: pnpm run build:ele
- name: Sync files
uses: SamKirkland/FTP-Deploy-Action@v4.3.6
uses: SamKirkland/FTP-Deploy-Action@v4.4.0
with:
server: ${{ secrets.PRO_FTP_HOST }}
username: ${{ secrets.WEB_ELE_FTP_ACCOUNT }}
@ -147,7 +147,7 @@ jobs:
run: pnpm run build:naive
- name: Sync files
uses: SamKirkland/FTP-Deploy-Action@v4.3.6
uses: SamKirkland/FTP-Deploy-Action@v4.4.0
with:
server: ${{ secrets.PRO_FTP_HOST }}
username: ${{ secrets.WEB_NAIVE_FTP_ACCOUNT }}

2
.gitignore

@ -57,3 +57,5 @@ vite.config.ts.*
.claude
.codex
skills-lock.json
.atomcode
.datalog

12
.npmrc

@ -1,13 +1 @@
registry=https://registry.npmmirror.com
public-hoist-pattern[]=lefthook
public-hoist-pattern[]=eslint
public-hoist-pattern[]=oxfmt
public-hoist-pattern[]=oxlint
public-hoist-pattern[]=stylelint
public-hoist-pattern[]=*postcss*
public-hoist-pattern[]=@commitlint/*
public-hoist-pattern[]=czg
strict-peer-dependencies=false
auto-install-peers=true
dedupe-peer-dependents=true

2
.vscode/settings.json

@ -38,7 +38,7 @@
// lint && format
"oxc.enable": true,
"oxc.typeAware": true,
"oxc.typeAware": false,
"oxc.configPath": "oxlint.config.ts",
"oxc.fmt.configPath": "oxfmt.config.ts",
"eslint.useFlatConfig": true,

85
apps/backend-mock/api/system/user/list.ts

@ -0,0 +1,85 @@
import { faker } from '@faker-js/faker';
import { eventHandler, getQuery } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response';
const formatterCN = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
function generateMockDataList(count: number) {
const dataList = [];
for (let i = 0; i < count; i++) {
const dataItem: Record<string, any> = {
id: faker.string.uuid(),
name: faker.commerce.product(),
status: faker.helpers.arrayElement([0, 1]),
createTime: formatterCN.format(
faker.date.between({ from: '2022-01-01', to: '2025-01-01' }),
),
deptId: faker.string.uuid(),
remark: faker.lorem.sentence(),
};
dataList.push(dataItem);
}
return dataList;
}
const mockData = generateMockDataList(100);
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
const {
page = 1,
pageSize = 20,
name,
id,
remark,
startTime,
endTime,
deptId,
status,
} = getQuery(event);
let listData = structuredClone(mockData);
if (name) {
listData = listData.filter((item) =>
item.name.toLowerCase().includes(String(name).toLowerCase()),
);
}
if (id) {
listData = listData.filter((item) =>
item.id.toLowerCase().includes(String(id).toLowerCase()),
);
}
if (remark) {
listData = listData.filter((item) =>
item.remark?.toLowerCase()?.includes(String(remark).toLowerCase()),
);
}
if (startTime) {
listData = listData.filter((item) => item.createTime >= startTime);
}
if (endTime) {
listData = listData.filter((item) => item.createTime <= endTime);
}
if (['0', '1'].includes(status as string)) {
listData = listData.filter((item) => item.status === Number(status));
}
if (deptId) {
listData = listData.filter((item) => item.deptId === deptId);
}
return usePageResponseSuccess(page as string, pageSize as string, listData);
});

8
apps/web-antd/src/adapter/component/index.ts

@ -131,8 +131,8 @@ const PreviewGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
const withDefaultPlaceholder = (
component: Component,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
@ -702,7 +702,9 @@ async function initComponentAdapter() {
modelValueProp: 'value',
}),
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
style: { width: '100%' },
}),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
Mentions: withDefaultPlaceholder(Mentions, 'input'),
// 自定义主要按钮

23
apps/web-antd/src/layouts/basic.vue

@ -14,7 +14,7 @@ import {
Notification,
UserDropdown,
} from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
@ -80,6 +80,7 @@ const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark();
const { isDark } = usePreferences();
const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
@ -179,10 +180,28 @@ watch(
() => ({
enable: preferences.app.watermark,
content: preferences.app.watermarkContent,
isDark: isDark.value,
}),
async ({ enable, content }) => {
async ({ enable, content, isDark: isDarkValue }) => {
if (enable) {
const watermarkColor = isDarkValue
? 'rgba(255, 255, 255, 0.12)'
: 'rgba(0, 0, 0, 0.12)';
await updateWatermark({
advancedStyle: {
colorStops: [
{
color: watermarkColor,
offset: 0,
},
{
color: watermarkColor,
offset: 1,
},
],
type: 'linear',
},
content:
content ||
`${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,

8
apps/web-antdv-next/src/adapter/component/index.ts

@ -136,8 +136,8 @@ const PreviewGroup = defineAsyncComponent(() =>
import('antdv-next/dist/image/index').then((res) => res.ImagePreviewGroup),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
const withDefaultPlaceholder = (
component: Component,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
@ -633,7 +633,9 @@ async function initComponentAdapter() {
modelValueProp: 'value',
}),
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
style: { width: '100%' },
}),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
Mentions: withDefaultPlaceholder(Mentions, 'input'),
// 自定义主要按钮

23
apps/web-antdv-next/src/layouts/basic.vue

@ -14,7 +14,7 @@ import {
Notification,
UserDropdown,
} from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
@ -80,6 +80,7 @@ const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark();
const { isDark } = usePreferences();
const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
@ -179,10 +180,28 @@ watch(
() => ({
enable: preferences.app.watermark,
content: preferences.app.watermarkContent,
isDark: isDark.value,
}),
async ({ enable, content }) => {
async ({ enable, content, isDark: isDarkValue }) => {
if (enable) {
const watermarkColor = isDarkValue
? 'rgba(255, 255, 255, 0.12)'
: 'rgba(0, 0, 0, 0.12)';
await updateWatermark({
advancedStyle: {
colorStops: [
{
color: watermarkColor,
offset: 0,
},
{
color: watermarkColor,
offset: 1,
},
],
type: 'linear',
},
content:
content ||
`${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,

8
apps/web-ele/src/adapter/component/index.ts

@ -141,8 +141,8 @@ const ElUpload = defineAsyncComponent(() =>
]).then(([res]) => res.ElUpload),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
const withDefaultPlaceholder = (
component: Component,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
@ -284,7 +284,9 @@ async function initComponentAdapter() {
inputComponent: ElInput,
}),
Input: withDefaultPlaceholder(ElInput, 'input'),
InputNumber: withDefaultPlaceholder(ElInputNumber, 'input'),
InputNumber: withDefaultPlaceholder(ElInputNumber, 'input', {
style: { width: '100%' },
}),
RadioGroup: (props, { attrs, slots }) => {
let defaultSlot;
if (Reflect.has(slots, 'default')) {

23
apps/web-ele/src/layouts/basic.vue

@ -14,7 +14,7 @@ import {
Notification,
UserDropdown,
} from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
@ -80,6 +80,7 @@ const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark();
const { isDark } = usePreferences();
const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
@ -179,10 +180,28 @@ watch(
() => ({
enable: preferences.app.watermark,
content: preferences.app.watermarkContent,
isDark: isDark.value,
}),
async ({ enable, content }) => {
async ({ enable, content, isDark: isDarkValue }) => {
if (enable) {
const watermarkColor = isDarkValue
? 'rgba(255, 255, 255, 0.12)'
: 'rgba(0, 0, 0, 0.12)';
await updateWatermark({
advancedStyle: {
colorStops: [
{
color: watermarkColor,
offset: 0,
},
{
color: watermarkColor,
offset: 1,
},
],
type: 'linear',
},
content:
content ||
`${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,

8
apps/web-naive/src/adapter/component/index.ts

@ -84,8 +84,8 @@ const NUpload = defineAsyncComponent(() =>
import('naive-ui/es/upload').then((res) => res.NUpload),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
const withDefaultPlaceholder = (
component: Component,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
@ -225,7 +225,9 @@ async function initComponentAdapter() {
inputComponent: NInput,
}),
Input: withDefaultPlaceholder(NInput, 'input'),
InputNumber: withDefaultPlaceholder(NInputNumber, 'input'),
InputNumber: withDefaultPlaceholder(NInputNumber, 'input', {
style: { width: '100%' },
}),
RadioGroup: (props, { attrs, slots }) => {
let defaultSlot;
if (Reflect.has(slots, 'default')) {

23
apps/web-naive/src/layouts/basic.vue

@ -14,7 +14,7 @@ import {
Notification,
UserDropdown,
} from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
@ -80,6 +80,7 @@ const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark();
const { isDark } = usePreferences();
const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
@ -179,10 +180,28 @@ watch(
() => ({
enable: preferences.app.watermark,
content: preferences.app.watermarkContent,
isDark: isDark.value,
}),
async ({ enable, content }) => {
async ({ enable, content, isDark: isDarkValue }) => {
if (enable) {
const watermarkColor = isDarkValue
? 'rgba(255, 255, 255, 0.12)'
: 'rgba(0, 0, 0, 0.12)';
await updateWatermark({
advancedStyle: {
colorStops: [
{
color: watermarkColor,
offset: 0,
},
{
color: watermarkColor,
offset: 1,
},
],
type: 'linear',
},
content:
content ||
`${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,

8
apps/web-tdesign/src/adapter/component/index.ts

@ -89,8 +89,8 @@ const TreeSelect = defineAsyncComponent(
);
const Upload = defineAsyncComponent(() => import('tdesign-vue-next/es/upload'));
const withDefaultPlaceholder = <T extends Component>(
component: T,
const withDefaultPlaceholder = (
component: Component,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
@ -239,7 +239,9 @@ async function initComponentAdapter() {
modelValueProp: 'value',
}),
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
style: { width: '100%' },
}),
// InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
// Mentions: withDefaultPlaceholder(Mentions, 'input'),
// 自定义主要按钮

23
apps/web-tdesign/src/layouts/basic.vue

@ -14,7 +14,7 @@ import {
Notification,
UserDropdown,
} from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
@ -80,6 +80,7 @@ const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark();
const { isDark } = usePreferences();
const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
@ -179,10 +180,28 @@ watch(
() => ({
enable: preferences.app.watermark,
content: preferences.app.watermarkContent,
isDark: isDark.value,
}),
async ({ enable, content }) => {
async ({ enable, content, isDark: isDarkValue }) => {
if (enable) {
const watermarkColor = isDarkValue
? 'rgba(255, 255, 255, 0.12)'
: 'rgba(0, 0, 0, 0.12)';
await updateWatermark({
advancedStyle: {
colorStops: [
{
color: watermarkColor,
offset: 0,
},
{
color: watermarkColor,
offset: 1,
},
],
type: 'linear',
},
content:
content ||
`${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,

2
docs/.vitepress/build/dayjs-loader.mjs

@ -0,0 +1,2 @@
import { register } from 'node:module';
register('./dayjs-resolve-hook.mjs', import.meta.url);

10
docs/.vitepress/build/dayjs-resolve-hook.mjs

@ -0,0 +1,10 @@
const DAYJS_SUBPATH_RE = /^dayjs\/(plugin|locale)\/([^./]+)$/;
/** @type {import('node:module').ResolveHook} */
export async function resolve(specifier, context, nextResolve) {
const match = specifier.match(DAYJS_SUBPATH_RE);
if (match) {
return nextResolve(`${specifier}.js`, context);
}
return nextResolve(specifier, context);
}

8
docs/.vitepress/config/en.mts

@ -198,6 +198,14 @@ function sidebarComponents(): DefaultTheme.SidebarItem[] {
link: 'common-ui/vben-ellipsis-text',
text: 'EllipsisText',
},
{
link: 'common-ui/vben-cropper',
text: 'Cropper',
},
{
link: 'common-ui/vben-tiptap',
text: 'Tiptap RichTextEditor',
},
],
},
];

4
docs/.vitepress/config/index.mts

@ -1,12 +1,12 @@
import { withPwa } from '@vite-pwa/vitepress';
import { defineConfigWithTheme } from 'vitepress';
import { defineConfig } from 'vitepress';
import { en } from './en.mts';
import { shared } from './shared.mts';
import { zh } from './zh.mts';
export default withPwa(
defineConfigWithTheme({
defineConfig({
...shared,
locales: {
en: {

23
docs/.vitepress/config/shared.mts

@ -5,6 +5,7 @@ import { resolve } from 'node:path';
import {
viteArchiverPlugin,
viteDayjsPlugin,
viteVxeTableImportsPlugin,
} from '@vben/vite-config';
@ -61,7 +62,7 @@ export const shared = defineConfig({
},
preprocessorOptions: {
scss: {
api: 'modern',
// api: 'modern',
},
},
},
@ -69,6 +70,7 @@ export const shared = defineConfig({
stringify: true,
},
plugins: [
viteDayjsPlugin(),
tailwindcss(),
GitChangelog({
mapAuthors: [
@ -85,9 +87,23 @@ export const shared = defineConfig({
name: 'Li Kui',
username: 'likui628',
},
{
mapByNameAliases: ['Jin Mao', 'jinmao'],
name: 'Jin Mao',
username: 'jinmao88',
},
{
name: 'Netfan',
username: 'mynetfan',
},
{
mapByNameAliases: ['xingyu4j', 'xingyu'],
name: 'xingyu4j',
username: 'xingyu4j',
},
],
repoURL: () => 'https://github.com/vbenjs/vue-vben-admin',
}),
}) as any,
GitChangelogMarkdownSection(),
viteArchiverPlugin({ outputDir: '.vitepress' }),
groupIconVitePlugin(),
@ -103,6 +119,7 @@ export const shared = defineConfig({
ssr: {
external: ['@vue/repl'],
noExternal: ['@v-c/picker'],
},
},
});
@ -113,7 +130,7 @@ function head(): HeadConfig[] {
[
'meta',
{
content: 'vben, vitejs, vite, shacdn-ui, vue',
content: 'vben, vitejs, vite, shadcn-ui, vue',
name: 'keywords',
},
],

18
docs/.vitepress/config/zh.mts

@ -196,6 +196,14 @@ function sidebarComponents(): DefaultTheme.SidebarItem[] {
link: 'common-ui/vben-ellipsis-text',
text: 'EllipsisText 省略文本',
},
{
link: 'common-ui/vben-cropper',
text: 'Cropper 图片裁剪',
},
{
link: 'common-ui/vben-tiptap',
text: 'Tiptap 富文本编辑器',
},
],
},
];
@ -329,8 +337,8 @@ export const search: DefaultTheme.AlgoliaSearchOptions['locales'] = {
footer: {
closeText: '关闭',
navigateText: '切换',
searchByText: '搜索提供者',
selectText: '选择',
poweredByText: '搜索提供者',
},
noResultsScreen: {
noResultsText: '无法找到相关结果',
@ -339,10 +347,10 @@ export const search: DefaultTheme.AlgoliaSearchOptions['locales'] = {
suggestedQueryText: '你可以尝试查询',
},
searchBox: {
cancelButtonAriaLabel: '取消',
cancelButtonText: '取消',
resetButtonAriaLabel: '清除查询条件',
resetButtonTitle: '清除查询条件',
closeButtonAriaLabel: '取消',
closeButtonText: '取消',
clearButtonAriaLabel: '清除查询条件',
clearButtonTitle: '清除查询条件',
},
startScreen: {
favoriteSearchesTitle: '收藏',

2
docs/.vitepress/theme/components/site-layout.vue

@ -10,7 +10,7 @@ import {
// import { useAntdDesignTokens } from '@vben/hooks';
// import { initPreferences } from '@vben/preferences';
import { ConfigProvider, theme } from 'ant-design-vue';
import { ConfigProvider, theme } from 'antdv-next';
import mediumZoom from 'medium-zoom';
import { useRoute } from 'vitepress';
import DefaultTheme from 'vitepress/theme';

5
docs/package.json

@ -2,8 +2,9 @@
"name": "@vben/docs",
"version": "5.7.0",
"private": true,
"type": "module",
"scripts": {
"build": "vitepress build",
"build": "cross-env NODE_OPTIONS=\"--import ./.vitepress/build/dayjs-loader.mjs\" vitepress build",
"dev": "vitepress dev",
"docs:preview": "vitepress preview"
},
@ -19,7 +20,7 @@
"@vben/locales": "workspace:*",
"@vben/plugins": "workspace:*",
"@vben/styles": "workspace:*",
"ant-design-vue": "catalog:",
"antdv-next": "catalog:",
"lucide-vue-next": "catalog:",
"medium-zoom": "catalog:",
"reka-ui": "catalog:",

11
docs/src/_env/adapter/component.ts

@ -26,16 +26,15 @@ import {
notification,
Radio,
RadioGroup,
RangePicker,
Rate,
Select,
Space,
Switch,
Textarea,
TextArea,
TimePicker,
TreeSelect,
Upload,
} from 'ant-design-vue';
} from 'antdv-next';
const withDefaultPlaceholder = <T extends Component>(
component: T,
@ -98,12 +97,12 @@ async function initComponentAdapter() {
},
Radio,
RadioGroup,
RangePicker,
RangePicker: withDefaultPlaceholder(DatePicker.RangePicker, 'select'),
Rate,
Select: withDefaultPlaceholder(Select, 'select'),
Space,
Switch,
Textarea: withDefaultPlaceholder(Textarea, 'input'),
Textarea: withDefaultPlaceholder(TextArea, 'input'),
TimePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload,
@ -118,7 +117,7 @@ async function initComponentAdapter() {
copyPreferencesSuccess: (title, content) => {
notification.success({
description: content,
message: title,
title,
placement: 'bottomRight',
});
},

2
docs/src/_env/adapter/vxe-table.ts

@ -2,7 +2,7 @@ import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { Button, Image } from 'ant-design-vue';
import { Button, Image } from 'antdv-next';
import { useVbenForm } from './form';

2
docs/src/components/common-ui/vben-api-component.md

@ -22,7 +22,7 @@ outline: deep
<script lang="ts" setup>
import { ApiComponent } from '@vben/common-ui';
import { Cascader } from 'ant-design-vue';
import { Cascader } from 'antdv-next';
const treeData: Record<string, any> = [
{

172
docs/src/components/common-ui/vben-cropper.md

@ -0,0 +1,172 @@
---
outline: deep
---
# Vben Cropper 图片裁剪
`VCropper` 是一个纯原生实现的图片裁剪组件,支持自由比例和固定比例裁剪,可通过方法调用获取裁剪后的图片。
> 如果文档内没有参数说明,可以尝试在在线示例内寻找
::: info 写在前面
如果你觉得现有组件的封装不够理想,或者不完全符合你的需求,可以直接使用原生组件,亦或亲手封装一个适合的组件。框架提供的组件并非束缚,使用与否,完全取决于你的需求与自由。
:::
## 基础用法
最基本的图片裁剪,支持自由比例调整。
<DemoPreview dir="demos/vben-cropper/basic" />
## 固定比例裁剪
通过 `aspectRatio` 属性设置裁剪比例,格式为 `"宽:高"`,如 `"1:1"`、`"16:9"`、`"3:4"` 等。
<DemoPreview dir="demos/vben-cropper/aspect-ratio" />
## API
### Props
| 属性名 | 描述 | 类型 | 默认值 |
| ------------- | ------------------------------------- | -------- | ------ |
| `img` | 图片地址(必填) | `string` | - |
| `width` | 容器宽度 | `number` | `500` |
| `height` | 容器高度 | `number` | `400` |
| `aspectRatio` | 裁剪比例,格式如 `"1:1"`、`"16:9"` 等 | `string` | - |
### Methods
通过 `ref` 调用组件方法:
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { VCropper } from '@vben/common-ui';
const cropperRef = ref<InstanceType<typeof VCropper>>();
const handleCrop = async () => {
const result = await cropperRef.value?.getCropImage();
// result 为 Blob 或 base64 字符串
};
</script>
```
#### getCropImage
裁剪并获取图片。
```ts
interface GetCropImageOptions {
/** 输出图片格式 */
format?: 'image/jpeg' | 'image/png';
/** 压缩质量(0-1),仅对 jpeg 格式有效 */
quality?: number;
/** 输出类型 */
outputType?: 'base64' | 'blob';
/** 目标宽度(可选,不传则为原始裁剪宽度) */
targetWidth?: number;
/** 目标高度(可选,不传则为原始裁剪高度) */
targetHeight?: number;
}
getCropImage(
format?: 'image/jpeg' | 'image/png',
quality?: number,
outputType?: 'base64' | 'blob',
targetWidth?: number,
targetHeight?: number,
): Promise<Blob | string | undefined>
```
**参数说明:**
| 参数 | 类型 | 默认值 | 描述 |
| --- | --- | --- | --- |
| `format` | `'image/jpeg' \| 'image/png'` | `'image/png'` | 输出图片格式 |
| `quality` | `number` | `0.92` | 压缩质量(0-1),仅 jpeg 有效 |
| `outputType` | `'base64' \| 'blob'` | `'blob'` | 输出类型,base64 字符串或 Blob 对象 |
| `targetWidth` | `number` | - | 目标宽度,不传则使用原始裁剪宽度 |
| `targetHeight` | `number` | - | 目标高度,不传则使用原始裁剪高度 |
## 功能特性
### 裁剪操作
- **拖拽移动** - 拖拽裁剪框中心区域移动裁剪位置
- **边角调整** - 拖拽四角调整裁剪框大小
- **边缘调整** - 拖拽四边中点调整单边
### 比例控制
- **自由比例** - 不设置 `aspectRatio` 时,可自由调整任意比例
- **固定比例** - 设置 `aspectRatio` 后,裁剪框始终保持设定比例
### 高清屏适配
组件自动适配 Retina 等高清屏幕,保证输出图片清晰无模糊。
### 图片适配
- 图片自动等比缩放以完整显示在容器内
- 支持本地图片和网络图片
- 网络图片需目标服务端支持 CORS 才能导出裁剪结果
## 使用示例
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { VCropper } from '@vben/common-ui';
const cropperRef = ref<InstanceType<typeof VCropper>>();
const imageUrl = ref('https://example.com/image.jpg');
const croppedImage = ref('');
// 获取裁剪后的 Blob 对象
const handleCropBlob = async () => {
const blob = await cropperRef.value?.getCropImage('image/jpeg', 0.9, 'blob');
if (blob instanceof Blob) {
// 上传到服务器或创建预览URL
const url = URL.createObjectURL(blob);
croppedImage.value = url;
}
};
// 获取裁剪后的 base64 字符串
const handleCropBase64 = async () => {
const base64 = await cropperRef.value?.getCropImage('image/png', 1, 'base64');
if (typeof base64 === 'string') {
croppedImage.value = base64;
}
};
// 导出指定尺寸
const handleCropWithSize = async () => {
const blob = await cropperRef.value?.getCropImage(
'image/jpeg',
0.9,
'blob',
200, // 目标宽度
200, // 目标高度
);
};
</script>
<template>
<div>
<VCropper
ref="cropperRef"
:img="imageUrl"
:width="500"
:height="400"
aspect-ratio="1:1"
/>
<button @click="handleCropBlob">裁剪</button>
<img v-if="croppedImage" :src="croppedImage" />
</div>
</template>
```

2
docs/src/components/common-ui/vben-form.md

@ -117,7 +117,7 @@ import {
TimePicker,
TreeSelect,
Upload,
} from 'ant-design-vue';
} from 'antdv-next';
const withDefaultPlaceholder = <T extends Component>(
component: T,

199
docs/src/components/common-ui/vben-tiptap.md

@ -0,0 +1,199 @@
---
outline: deep
---
# Vben Tiptap 富文本编辑器
基于 [Tiptap](https://tiptap.dev/) 构建的富文本编辑器组件,支持丰富的文本格式化、图片插入、图片上传等功能。
> 如果文档内没有参数说明,可以尝试在在线示例内寻找
::: info 写在前面
如果你觉得现有组件的封装不够理想,或者不完全符合你的需求,可以直接使用原生组件,亦或亲手封装一个适合的组件。框架提供的组件并非束缚,使用与否,完全取决于你的需求与自由。
:::
## 基础用法
<DemoPreview dir="demos/vben-tiptap/basic" />
## 组件列表
### VbenTiptap
富文本编辑器主组件。
### VbenTiptapPreview
富文本内容预览组件,用于只读展示编辑器内容。
## API
### Props
| 属性名 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
| `modelValue` (v-model) | 编辑器内容(HTML字符串) | `string` | `''` |
| `editable` | 是否可编辑 | `boolean` | `true` |
| `toolbar` | 是否显示工具栏 | `boolean` | `true` |
| `previewable` | 是否显示预览按钮 | `boolean` | `true` |
| `placeholder` | 占位提示文本 | `string` | - |
| `minHeight` | 最小高度 | `number \| string` | `240` |
| `maxHeight` | 最大高度 | `number \| string` | `400` |
| `extensions` | 自定义 Tiptap 扩展配置 | `Extensions` | - |
| `imageUpload` | 图片上传配置 | `ImageUploadOptions` | - |
### Events
| 事件名 | 说明 | 参数类型 |
| -------- | -------------- | ----------------------- |
| `change` | 内容变化时触发 | `VbenTiptapChangeEvent` |
#### VbenTiptapChangeEvent
```ts
interface VbenTiptapChangeEvent {
html: string; // HTML 内容
json: JSONContent; // JSON 结构内容
text: string; // 纯文本内容
}
```
### ImageUploadOptions
图片上传配置项:
```ts
interface ImageUploadOptions {
/** 允许的文件类型,默认 'image/*' */
accept?: string;
/** 最大文件大小(字节),默认 5MB */
maxSize?: number;
/** 上传失败回调,未提供时使用 alert 弹窗提示 */
onUploadError?: (error: unknown) => void;
/** 上传函数,返回图片 URL */
upload: (
file: File,
onProgress?: (percent: number) => void,
) => Promise<string>;
}
```
### VbenTiptapPreview Props
| 属性名 | 说明 | 类型 | 默认值 |
| ----------- | ------------------ | ------------------ | ------ |
| `content` | 要预览的 HTML 内容 | `string` | `''` |
| `minHeight` | 最小高度 | `number \| string` | `160` |
| `class` | 自定义类名 | `any` | - |
## 工具栏功能
编辑器工具栏提供以下功能:
### 格式化
- **撤销/重做** - 撤销或重做编辑操作
- **清除格式** - 清除选中文本的所有格式
- **粗体** - 加粗文本
- **斜体** - 斜体文本
- **下划线** - 下划线文本
- **删除线** - 删除线文本
- **行内代码** - 行内代码标记
### 结构
- **标题** - 段落、H1-H4 标题切换
- **有序列表** - 有序编号列表
- **无序列表** - 无序符号列表
- **引用块** - 引用块样式
- **代码块** - 多行代码块
### 链接与图片
- **插入链接** - 插入或编辑超链接
- **移除链接** - 移除选中文本的链接
- **插入图片** - 通过 URL 插入图片
### 样式
- **文字颜色** - 设置文字颜色(预设色板)
- **背景颜色** - 设置文字背景高亮颜色
### 对齐
- **左对齐** - 文本左对齐
- **居中对齐** - 文本居中对齐
- **右对齐** - 文本右对齐
### 其他
- **预览** - 在弹窗中预览编辑内容
## 图片上传
<DemoPreview dir="demos/vben-tiptap/image-upload" />
当配置 `imageUpload` 时,工具栏的图片按钮会变为下拉菜单,包含「本地上传」和「URL 插入」两个选项。
### 上传方式
支持三种图片上传方式:
1. **文件选择** - 点击工具栏本地上传按钮
2. **拖拽上传** - 直接拖拽图片到编辑器区域
3. **粘贴上传** - 粘贴图片到编辑器
### 上传进度显示
上传过程中会显示:
- **加载指示器** - 旋转动画指示上传进行中
- **进度条** - 当上传函数提供 `onProgress` 回调时,显示进度条
### 文件校验
- `accept` - 指定允许的文件类型(MIME类型)
- `maxSize` - 最大文件大小限制(字节)
- 校验失败时会触发 `onUploadError` 回调或默认 alert 提示
::: warning 注意事项
- 仅支持单张图片上传,多图拖拽/粘贴时会提示并仅处理第一张
- 上传中不要保存编辑器内容(`getHTML()`),因为此时图片 URL 为临时 blob URL
- 自定义 `extensions` 时,图片上传功能将不显示(因为可能缺少 uploadImage 命令)
:::
## 自定义扩展
通过 `extensions` 属性可以传入自定义的 Tiptap 扩展配置:
```vue
<script setup lang="ts">
import { VbenTiptap } from '@vben/plugins/tiptap';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
const extensions = [
StarterKit,
Underline,
// 其他扩展...
];
</script>
<template>
<VbenTiptap v-model="content" :extensions="extensions" />
</template>
```
::: warning 自定义扩展注意事项
使用自定义 `extensions` 时:
- 默认扩展配置将不会生效
- 图片上传功能不可用(工具栏不显示上传选项)
- 需自行配置所需的编辑器功能
:::

2
docs/src/components/common-ui/vben-vxe-table.md

@ -25,7 +25,7 @@ import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { Button, Image } from 'ant-design-vue';
import { Button, Image } from 'antdv-next';
import { useVbenForm } from './form';

2
docs/src/demos/vben-alert/alert/index.vue

@ -3,7 +3,7 @@ import { h } from 'vue';
import { alert, VbenButton } from '@vben/common-ui';
import { Result } from 'ant-design-vue';
import { Result } from 'antdv-next';
function showAlert() {
alert('This is an alert message');

2
docs/src/demos/vben-alert/confirm/index.vue

@ -3,7 +3,7 @@ import { h, ref } from 'vue';
import { alert, confirm, VbenButton } from '@vben/common-ui';
import { Checkbox, message } from 'ant-design-vue';
import { Checkbox, message } from 'antdv-next';
function showConfirm() {
confirm('This is an alert message')

2
docs/src/demos/vben-alert/prompt/index.vue

@ -3,7 +3,7 @@ import { h } from 'vue';
import { alert, prompt, useAlertContext, VbenButton } from '@vben/common-ui';
import { Input, RadioGroup, Select } from 'ant-design-vue';
import { Input, RadioGroup, Select } from 'antdv-next';
import { BadgeJapaneseYen } from 'lucide-vue-next';
function showPrompt() {

2
docs/src/demos/vben-api-component/cascader/index.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { ApiComponent } from '@vben/common-ui';
import { Cascader } from 'ant-design-vue';
import { Cascader } from 'antdv-next';
const treeData: Record<string, any> = [
{

102
docs/src/demos/vben-cropper/aspect-ratio/index.vue

@ -0,0 +1,102 @@
<script lang="ts" setup>
import { onBeforeUnmount, ref } from 'vue';
import { VCropper } from '@vben/common-ui';
const cropperRef = ref<InstanceType<typeof VCropper>>();
const aspectRatio = ref('1:1');
const imageUrl = ref('https://picsum.photos/seed/cropper-ratio/800/600');
const croppedImage = ref('');
const aspectOptions = [
{ label: '1:1 (正方形)', value: '1:1' },
{ label: '16:9 (宽屏)', value: '16:9' },
{ label: '4:3 (标准)', value: '4:3' },
{ label: '3:4 (竖版)', value: '3:4' },
{ label: '3:2 (照片)', value: '3:2' },
];
// 释放旧的 object URL 以避免内存泄漏
const revokeCroppedImage = () => {
if (croppedImage.value?.startsWith('blob:')) {
URL.revokeObjectURL(croppedImage.value);
}
};
const handleCrop = async () => {
const blob = await cropperRef.value?.getCropImage('image/jpeg', 0.9, 'blob');
if (blob instanceof Blob) {
// 释放旧的 URL
revokeCroppedImage();
croppedImage.value = URL.createObjectURL(blob);
}
};
const handleReset = () => {
// 释放 URL
revokeCroppedImage();
croppedImage.value = '';
imageUrl.value = `https://picsum.photos/seed/cropper-${Date.now()}/800/600`;
};
// 组件卸载时清理
onBeforeUnmount(() => {
revokeCroppedImage();
});
</script>
<template>
<div>
<div class="mb-4">
<label class="text-sm text-gray-500 mr-2">选择比例:</label>
<select v-model="aspectRatio" class="px-3 py-1 border rounded text-sm">
<option
v-for="option in aspectOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
<VCropper
ref="cropperRef"
:img="imageUrl"
:width="500"
:height="300"
:aspect-ratio="aspectRatio"
/>
<div class="mt-4 flex gap-2">
<button
class="px-4 py-2 bg-blue-500 rounded hover:bg-blue-600"
@click="handleCrop"
>
裁剪图片
</button>
<button
class="px-4 py-2 bg-gray-500 rounded hover:bg-gray-600"
@click="handleReset"
>
重置
</button>
</div>
<div v-if="croppedImage" class="mt-4">
<p class="text-sm text-gray-500 mb-2">
裁剪结果 (比例: {{ aspectRatio }}):
</p>
<img :src="croppedImage" class="max-w-full rounded border" />
</div>
<div class="mt-4">
<p class="text-sm text-gray-500">提示:</p>
<ul class="mt-2 text-xs text-gray-400 list-disc pl-4">
<li>设置固定比例后,裁剪框始终维持该比例</li>
<li>切换比例会自动重新计算裁剪框大小</li>
<li>比例格式为 "宽:高",如 "16:9"</li>
</ul>
</div>
</div>
</template>

70
docs/src/demos/vben-cropper/basic/index.vue

@ -0,0 +1,70 @@
<script lang="ts" setup>
import { onBeforeUnmount, ref } from 'vue';
import { VCropper } from '@vben/common-ui';
const cropperRef = ref<InstanceType<typeof VCropper>>();
const imageUrl = ref('https://picsum.photos/seed/cropper-demo/800/600');
const croppedImage = ref('');
// 释放旧的 object URL 以避免内存泄漏
const revokeCroppedImage = () => {
if (croppedImage.value?.startsWith('blob:')) {
URL.revokeObjectURL(croppedImage.value);
}
};
const handleCrop = async () => {
const blob = await cropperRef.value?.getCropImage('image/jpeg', 0.9, 'blob');
if (blob instanceof Blob) {
// 释放旧的 URL
revokeCroppedImage();
croppedImage.value = URL.createObjectURL(blob);
}
};
const handleReset = () => {
// 释放 URL
revokeCroppedImage();
croppedImage.value = '';
// 重新加载图片以重置裁剪框
imageUrl.value = `https://picsum.photos/seed/cropper-${Date.now()}/800/600`;
};
// 组件卸载时清理
onBeforeUnmount(() => {
revokeCroppedImage();
});
</script>
<template>
<div>
<VCropper ref="cropperRef" :img="imageUrl" :width="500" :height="300" />
<div class="mt-4 flex gap-2">
<button
class="px-4 py-2 bg-blue-500 rounded hover:bg-blue-600"
@click="handleCrop"
>
裁剪图片
</button>
<button
class="px-4 py-2 bg-gray-500 rounded hover:bg-gray-600"
@click="handleReset"
>
重置
</button>
</div>
<div v-if="croppedImage" class="mt-4">
<p class="text-sm text-gray-500 mb-2">裁剪结果:</p>
<img :src="croppedImage" class="max-w-full rounded border" />
</div>
<div class="mt-4">
<p class="text-sm text-gray-500">提示:</p>
<ul class="mt-2 text-xs text-gray-400 list-disc pl-4">
<li>拖拽裁剪框中心区域可移动裁剪位置</li>
<li>拖拽四角或四边可调整裁剪框大小</li>
<li>默认为自由比例,可调整为任意比例</li>
</ul>
</div>
</div>
</template>

2
docs/src/demos/vben-form/api/index.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Button, message, Space } from 'ant-design-vue';
import { Button, message, Space } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';

2
docs/src/demos/vben-form/basic/index.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';

2
docs/src/demos/vben-form/custom/index.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { h } from 'vue';
import { Input, message } from 'ant-design-vue';
import { Input, message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';

2
docs/src/demos/vben-form/dynamic/index.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';

2
docs/src/demos/vben-form/query/index.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';

2
docs/src/demos/vben-form/rules/index.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form';

2
docs/src/demos/vben-form/value-format/index.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { Button, Card, message, Space, Tag } from 'ant-design-vue';
import { Button, Card, message, Space, Tag } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';

19
docs/src/demos/vben-tiptap/basic/index.vue

@ -0,0 +1,19 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { VbenTiptap } from '@vben/plugins/tiptap';
const content = ref('<p>开始编辑你的内容...</p>');
</script>
<template>
<div>
<VbenTiptap v-model="content" />
<div class="mt-4">
<p class="text-sm text-gray-500">当前内容:</p>
<pre class="mt-2 p-2 bg-gray-100 rounded text-xs overflow-auto max-h-40">
{{ content }}
</pre>
</div>
</div>
</template>

45
docs/src/demos/vben-tiptap/image-upload/index.vue

@ -0,0 +1,45 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { type ImageUploadOptions, VbenTiptap } from '@vben/plugins/tiptap';
const content = ref('');
// Mock upload function with progress simulation
const imageUpload: ImageUploadOptions = {
accept: 'image/jpeg,image/png,image/gif,image/webp',
maxSize: 5 * 1024 * 1024, // 5MB
upload: async (_file, onProgress) => {
// Simulate upload progress
for (let i = 0; i <= 100; i += 10) {
await new Promise((resolve) => setTimeout(resolve, 100));
onProgress?.(i);
}
// Return a mock image URL (using picsum for demo)
return `https://picsum.photos/seed/${Date.now()}/800/400`;
},
onUploadError: (error) => {
console.error('Upload error:', error);
},
};
</script>
<template>
<div>
<VbenTiptap
v-model="content"
:image-upload="imageUpload"
placeholder="尝试拖拽或粘贴图片..."
/>
<div class="mt-4">
<p class="text-sm text-gray-500">提示:</p>
<ul class="mt-2 text-xs text-gray-400 list-disc pl-4">
<li>点击工具栏图片按钮可选择本地上传或 URL 插入</li>
<li>拖拽图片到编辑器区域可直接上传</li>
<li>粘贴图片也会触发上传</li>
<li>上传过程中会显示进度条</li>
</ul>
</div>
</div>
</template>

2
docs/src/demos/vben-vxe-table/basic/index.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { VxeGridListeners, VxeGridProps } from '#/adapter/vxe-table';
import { Button, message } from 'ant-design-vue';
import { Button, message } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

2
docs/src/demos/vben-vxe-table/custom-cell/index.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Button, Image, Switch, Tag } from 'ant-design-vue';
import { Button, Image, Switch, Tag } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

2
docs/src/demos/vben-vxe-table/edit-row/index.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Button, message } from 'ant-design-vue';
import { Button, message } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

2
docs/src/demos/vben-vxe-table/fixed/index.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

2
docs/src/demos/vben-vxe-table/form/index.vue

@ -2,7 +2,7 @@
import type { VbenFormProps } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

2
docs/src/demos/vben-vxe-table/remote/index.vue

@ -3,7 +3,7 @@ import type { DemoTableApi } from '../mock-api';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

2
docs/src/demos/vben-vxe-table/tree/index.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';

2
docs/src/en/components/common-ui/vben-api-component.md

@ -20,7 +20,7 @@ The current wrapper flow is:
<script lang="ts" setup>
import { ApiComponent } from '@vben/common-ui';
import { Cascader } from 'ant-design-vue';
import { Cascader } from 'antdv-next';
function fetchApi() {
return Promise.resolve([

159
docs/src/en/components/common-ui/vben-cropper.md

@ -0,0 +1,159 @@
---
outline: deep
---
# Vben Cropper Image Cropping
`VCropper` is a pure native image cropping component that supports both free and fixed aspect ratio cropping, with method-based access to cropped results.
> If some details are not obvious from the docs, check the live demos as well.
::: info Note
If you feel the current component implementation doesn't meet your needs, you can use native components directly or create your own component. The components provided by the framework are not constraints - use them at your discretion.
:::
## Basic Usage
Basic image cropping with free aspect ratio adjustment.
<DemoPreview dir="demos/vben-cropper/basic" />
## Fixed Aspect Ratio
Set the cropping ratio via the `aspectRatio` prop. The format is `"width:height"`, e.g. `"1:1"`, `"16:9"`, `"3:4"`.
<DemoPreview dir="demos/vben-cropper/aspect-ratio" />
## API
### Props
| Property | Description | Type | Default |
| ------------- | ---------------------------------- | -------- | ------- |
| `img` | Image URL (required) | `string` | - |
| `width` | Container width | `number` | `500` |
| `height` | Container height | `number` | `400` |
| `aspectRatio` | Crop ratio, e.g. `"1:1"`, `"16:9"` | `string` | - |
### Methods
Call component methods via `ref`:
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { VCropper } from '@vben/common-ui';
const cropperRef = ref<InstanceType<typeof VCropper>>();
const handleCrop = async () => {
const result = await cropperRef.value?.getCropImage();
// result is a Blob or base64 string
};
</script>
```
#### getCropImage
Crop and retrieve the image.
```ts
getCropImage(
format?: 'image/jpeg' | 'image/png',
quality?: number,
outputType?: 'base64' | 'blob',
targetWidth?: number,
targetHeight?: number,
): Promise<Blob | string | undefined>
```
**Parameters:**
| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `format` | `'image/jpeg' \| 'image/png'` | `'image/png'` | Output image format |
| `quality` | `number` | `0.92` | Compression quality (0-1), only effective for jpeg |
| `outputType` | `'base64' \| 'blob'` | `'blob'` | Output type, base64 string or Blob object |
| `targetWidth` | `number` | - | Target width, defaults to original crop width if omitted |
| `targetHeight` | `number` | - | Target height, defaults to original crop height if omitted |
## Features
### Cropping Operations
- **Drag to Move** - Drag the center area of the crop box to move its position
- **Corner Resize** - Drag the four corners to resize the crop box
- **Edge Resize** - Drag the midpoints of edges to adjust a single side
### Aspect Ratio Control
- **Free Ratio** - Without `aspectRatio`, adjust the crop box to any ratio
- **Fixed Ratio** - With `aspectRatio` set, the crop box maintains the specified ratio
### HiDPI Support
The component automatically adapts to Retina and other high-DPI screens, ensuring crisp output images.
### Image Fitting
- Images are automatically scaled to fit within the container
- Supports both local and remote images
- Remote images require CORS support from the server to export cropped results
## Usage Example
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { VCropper } from '@vben/common-ui';
const cropperRef = ref<InstanceType<typeof VCropper>>();
const imageUrl = ref('https://example.com/image.jpg');
const croppedImage = ref('');
// Get cropped Blob
const handleCropBlob = async () => {
const blob = await cropperRef.value?.getCropImage('image/jpeg', 0.9, 'blob');
if (blob instanceof Blob) {
// Upload to server or create preview URL
const url = URL.createObjectURL(blob);
croppedImage.value = url;
}
};
// Get cropped base64 string
const handleCropBase64 = async () => {
const base64 = await cropperRef.value?.getCropImage('image/png', 1, 'base64');
if (typeof base64 === 'string') {
croppedImage.value = base64;
}
};
// Export with specific dimensions
const handleCropWithSize = async () => {
const blob = await cropperRef.value?.getCropImage(
'image/jpeg',
0.9,
'blob',
200, // target width
200, // target height
);
};
</script>
<template>
<div>
<VCropper
ref="cropperRef"
:img="imageUrl"
:width="500"
:height="400"
aspect-ratio="1:1"
/>
<button @click="handleCropBlob">Crop</button>
<img v-if="croppedImage" :src="croppedImage" />
</div>
</template>
```

2
docs/src/en/components/common-ui/vben-form.md

@ -103,7 +103,7 @@ import {
TimePicker,
TreeSelect,
Upload,
} from 'ant-design-vue';
} from 'antdv-next';
const withDefaultPlaceholder = <T extends Component>(
component: T,

199
docs/src/en/components/common-ui/vben-tiptap.md

@ -0,0 +1,199 @@
---
outline: deep
---
# Vben Tiptap Rich Text Editor
A rich text editor component built on [Tiptap](https://tiptap.dev/), supporting rich text formatting, image insertion, and image upload features.
> If some details are not obvious from the docs, check the live demos as well.
::: info Note
If you feel the current component implementation doesn't meet your needs, you can use native components directly or create your own component. The components provided by the framework are not constraints - use them at your discretion.
:::
## Basic Usage
<DemoPreview dir="demos/vben-tiptap/basic" />
## Component List
### VbenTiptap
Main rich text editor component.
### VbenTiptapPreview
Read-only preview component for displaying editor content.
## API
### Props
| Property | Description | Type | Default |
| --- | --- | --- | --- |
| `modelValue` (v-model) | Editor content (HTML string) | `string` | `''` |
| `editable` | Whether the editor is editable | `boolean` | `true` |
| `toolbar` | Whether to show the toolbar | `boolean` | `true` |
| `previewable` | Whether to show the preview button | `boolean` | `true` |
| `placeholder` | Placeholder text | `string` | - |
| `minHeight` | Minimum height | `number \| string` | `240` |
| `maxHeight` | Maximum height | `number \| string` | `400` |
| `extensions` | Custom Tiptap extensions | `Extensions` | - |
| `imageUpload` | Image upload configuration | `ImageUploadOptions` | - |
### Events
| Event | Description | Parameters |
| -------- | ------------------------------ | ----------------------- |
| `change` | Triggered when content changes | `VbenTiptapChangeEvent` |
#### VbenTiptapChangeEvent
```ts
interface VbenTiptapChangeEvent {
html: string; // HTML content
json: JSONContent; // JSON structure
text: string; // Plain text content
}
```
### ImageUploadOptions
Image upload configuration:
```ts
interface ImageUploadOptions {
/** Allowed file types, default 'image/*' */
accept?: string;
/** Max file size in bytes, default 5MB */
maxSize?: number;
/** Upload error callback, uses alert if not provided */
onUploadError?: (error: unknown) => void;
/** Upload function, returns image URL */
upload: (
file: File,
onProgress?: (percent: number) => void,
) => Promise<string>;
}
```
### VbenTiptapPreview Props
| Property | Description | Type | Default |
| ----------- | ----------------------- | ------------------ | ------- |
| `content` | HTML content to preview | `string` | `''` |
| `minHeight` | Minimum height | `number \| string` | `160` |
| `class` | Custom class name | `any` | - |
## Toolbar Features
The editor toolbar provides the following features:
### Formatting
- **Undo/Redo** - Undo or redo editing operations
- **Clear Formatting** - Remove all formatting from selected text
- **Bold** - Bold text
- **Italic** - Italic text
- **Underline** - Underline text
- **Strikethrough** - Strikethrough text
- **Inline Code** - Inline code mark
### Structure
- **Headings** - Paragraph, H1-H4 heading switching
- **Ordered List** - Numbered list
- **Bullet List** - Bulleted list
- **Blockquote** - Quote block style
- **Code Block** - Multi-line code block
### Links & Images
- **Insert Link** - Insert or edit hyperlinks
- **Remove Link** - Remove link from selected text
- **Insert Image** - Insert image via URL
### Style
- **Text Color** - Set text color (preset palette)
- **Highlight Color** - Set text background highlight color
### Alignment
- **Align Left** - Left align text
- **Align Center** - Center align text
- **Align Right** - Right align text
### Other
- **Preview** - Preview content in a modal
## Image Upload
<DemoPreview dir="demos/vben-tiptap/image-upload" />
When `imageUpload` is configured, the toolbar image button becomes a dropdown menu with "Upload" and "URL" options.
### Upload Methods
Three image upload methods are supported:
1. **File Selection** - Click the upload button in toolbar
2. **Drag & Drop** - Drag images directly into the editor
3. **Paste** - Paste images into the editor
### Upload Progress Display
During upload:
- **Loading Indicator** - Spinner animation indicating upload in progress
- **Progress Bar** - Shows progress bar when upload function provides `onProgress` callback
### File Validation
- `accept` - Specify allowed file types (MIME types)
- `maxSize` - Maximum file size limit (bytes)
- Validation failure triggers `onUploadError` callback or default alert
::: warning Important Notes
- Only single image upload is supported; multi-image drag/paste will show a prompt and process only the first image
- Do not save editor content (`getHTML()`) during upload as image URLs are temporary blob URLs
- When using custom `extensions`, the image upload feature will not be available (toolbar won't show upload option)
:::
## Custom Extensions
Pass custom Tiptap extension configurations via the `extensions` property:
```vue
<script setup lang="ts">
import { VbenTiptap } from '@vben/plugins/tiptap';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
const extensions = [
StarterKit,
Underline,
// Other extensions...
];
</script>
<template>
<VbenTiptap v-model="content" :extensions="extensions" />
</template>
```
::: warning Custom Extensions Note
When using custom `extensions`:
- Default extension configuration will not take effect
- Image upload feature is not available (toolbar won't show upload option)
- You need to configure all required editor features yourself
:::

10
docs/src/en/guide/essentials/external-module.md

@ -1,6 +1,6 @@
# External Modules
In addition to the external modules that are included by default in the project, sometimes we need to import other external modules. Let's take [ant-design-vue](https://antdv.com/components/overview) as an example:
In addition to the external modules that are included by default in the project, sometimes we need to import other external modules. Let's take [antdv-next](https://antdv.com/components/overview) as an example:
## Installing Dependencies
@ -13,7 +13,7 @@ In addition to the external modules that are included by default in the project,
```bash
# cd /path/to/your/package
pnpm add ant-design-vue
pnpm add antdv-next
```
## Usage
@ -22,9 +22,9 @@ pnpm add ant-design-vue
```ts
import { createApp } from 'vue';
import Antd from 'ant-design-vue';
import Antd from 'antdv-next';
import App from './App';
import 'ant-design-vue/dist/reset.css';
import 'antdv-next/dist/reset.css';
const app = createApp(App);
@ -43,7 +43,7 @@ app.use(Antd).mount('#app');
```vue
<script setup lang="ts">
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
</script>
<template>

2
docs/src/en/guide/essentials/server.md

@ -174,7 +174,7 @@ import {
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useAuthStore } from '#/store';

2
docs/src/en/guide/other/faq.md

@ -110,7 +110,7 @@ registry = https://registry.npmmirror.com/
If you encounter errors similar to the following, please check that the full project path (including all parent paths) does not contain Chinese, Japanese, or Korean characters. Otherwise, you will encounter a 404 error for the path, leading to the following issue:
```ts
[vite] Failed to resolve module import "ant-design-vue/dist/antd.css-vben-adminode_modulesant-design-vuedistantd.css". (imported by /@/setup/ant-design-vue/index.ts)
[vite] Failed to resolve module import "antdv-next/dist/antd.css-vben-adminode_modulesantdv-nextdistantd.css". (imported by /@/setup/antdv-next/index.ts)
```
## Console Route Warning Issue

10
docs/src/guide/essentials/external-module.md

@ -1,6 +1,6 @@
# 外部模块
除了项目默认引入的外部模块,有时我们还需要引入其他外部模块。我们以 [ant-design-vue](https://antdv.com/components/overview) 为例:
除了项目默认引入的外部模块,有时我们还需要引入其他外部模块。我们以 [antdv-next](https://antdv.com/components/overview) 为例:
## 安装依赖
@ -13,7 +13,7 @@
```bash
# cd /path/to/your/package
pnpm add ant-design-vue
pnpm add antdv-next
```
## 使用
@ -22,9 +22,9 @@ pnpm add ant-design-vue
```ts
import { createApp } from 'vue';
import Antd from 'ant-design-vue';
import Antd from 'antdv-next';
import App from './App';
import 'ant-design-vue/dist/reset.css';
import 'antdv-next/dist/reset.css';
const app = createApp(App);
@ -43,7 +43,7 @@ app.use(Antd).mount('#app');
```vue
<script setup lang="ts">
import { Button } from 'ant-design-vue';
import { Button } from 'antdv-next';
</script>
<template>

2
docs/src/guide/essentials/server.md

@ -204,7 +204,7 @@ import {
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { message } from 'antdv-next';
import { useAuthStore } from '#/store';

2
docs/src/guide/in-depth/check-updates.md

@ -60,7 +60,7 @@ pnpm add version-polling
```ts
import { h } from 'vue';
import { Button, notification } from 'ant-design-vue';
import { Button, notification } from 'antdv-next';
import { createVersionPolling } from 'version-polling';
createVersionPolling({

2
docs/src/guide/other/faq.md

@ -110,7 +110,7 @@ registry = https://registry.npmmirror.com/
如果出现类似以下错误,请检查项目全路径(包含所有父级路径)不能出现中文、日文、韩文。否则将会出现路径访问 404 导致以下问题
```ts
[vite] Failed to resolve module import "ant-design-vue/dist/antd.css-vben-adminode_modulesant-design-vuedistantd.css". (imported by /@/setup/ant-design-vue/index.ts)
[vite] Failed to resolve module import "antdv-next/dist/antd.css-vben-adminode_modulesantdv-nextdistantd.css". (imported by /@/setup/antdv-next/index.ts)
```
## 控制台路由警告问题

1
internal/lint-configs/oxlint-config/src/configs/comments.ts

@ -8,6 +8,7 @@ const comments: OxlintConfig = {
},
],
rules: {
'eslint/no-underscore-dangle': 'off',
'eslint-comments/no-aggregating-enable': 'error',
'eslint-comments/no-duplicate-disable': 'error',
'eslint-comments/no-unlimited-disable': 'error',

70
internal/vite-config/src/plugins/dayjs.ts

@ -0,0 +1,70 @@
import type { Plugin } from 'vite';
function viteDayjsPlugin(): Plugin {
return {
name: 'vite-dayjs-plugin',
enforce: 'pre',
async resolveId(source, importer, options) {
// 1) 已经使用了 dayjs/esm 的不处理
if (source.startsWith('dayjs/esm')) return null;
// 2) 根入口:dayjs -> dayjs/esm
if (source === 'dayjs') {
return await this.resolve('dayjs/esm', importer, {
skipSelf: true,
...options,
});
}
// 3) 插件入口的多种写法
// - dayjs/plugin/xxx.js -> dayjs/esm/plugin/xxx/index.js
// - dayjs/plugin/xxx -> dayjs/esm/plugin/xxx
const pluginWithJs = source.match(/^dayjs\/plugin\/([^/]+)\.js$/);
if (pluginWithJs) {
const target = `dayjs/esm/plugin/${pluginWithJs[1]}/index.js`;
return await this.resolve(target, importer, {
skipSelf: true,
...options,
});
}
const pluginBare = source.match(/^dayjs\/plugin\/([^/]+)$/);
if (pluginBare) {
const target = `dayjs/esm/plugin/${pluginBare[1]}`;
return await this.resolve(target, importer, {
skipSelf: true,
...options,
});
}
// 4) 处理多语言包
// - dayjs/locale/xxx.js -> dayjs/esm/locale/xxx.js
const localeWithJs = source.match(/^dayjs\/locale\/([^/]+)\.js$/);
if (localeWithJs) {
const target = `dayjs/esm/locale/${localeWithJs[1]}.js`;
return await this.resolve(target, importer, {
skipSelf: true,
...options,
});
}
const localeBare = source.match(/^dayjs\/locale\/([^/]+)$/);
if (localeBare) {
const target = `dayjs/esm/locale/${localeBare[1]}`;
return await this.resolve(target, importer, {
skipSelf: true,
...options,
});
}
return null;
},
config() {
return {
optimizeDeps: {
exclude: ['dayjs'],
},
};
},
};
}
export { viteDayjsPlugin };

7
internal/vite-config/src/plugins/index.ts

@ -18,6 +18,7 @@ import { VitePWA } from 'vite-plugin-pwa';
import viteVueDevTools from 'vite-plugin-vue-devtools';
import { viteArchiverPlugin } from './archiver';
import { viteDayjsPlugin } from './dayjs';
import { viteExtraAppConfigPlugin } from './extra-app-config';
import { viteHtmlPlugin } from './html';
import { viteImportMapPlugin } from './importmap';
@ -105,6 +106,7 @@ async function loadApplicationPlugins(
compressTypes,
extraAppConfig,
html,
dayjs,
i18n,
importmap,
importmapOptions,
@ -219,6 +221,10 @@ async function loadApplicationPlugins(
return [await viteArchiverPlugin(archiverPluginOptions)];
},
},
{
condition: dayjs,
plugins: () => [viteDayjsPlugin()],
},
]);
}
@ -247,6 +253,7 @@ export {
loadLibraryPlugins,
viteArchiverPlugin,
viteCompressPlugin,
viteDayjsPlugin,
viteDtsPlugin,
viteHtmlPlugin,
viteVisualizerPlugin,

5
internal/vite-config/src/typing.ts

@ -214,6 +214,11 @@ interface ApplicationPluginOptions extends CommonPluginOptions {
* @description 可选的压缩类型
*/
compressTypes?: ('brotli' | 'gzip')[];
/**
* 是否开启 dayjs 插件
* @default true
*/
dayjs?: boolean;
/**
* 是否抽离配置文件
* @default false

4
lefthook.yml

@ -50,10 +50,10 @@ pre-commit:
run: pnpm oxfmt {staged_files}
glob: '*.md'
lint-vue:
run: pnpm oxfmt {staged_files} && pnpm oxlint --fix {staged_files} && pnpm eslint --cache --fix {staged_files} && pnpm stylelint --fix --allow-empty-input {staged_files}
run: pnpm oxfmt {staged_files} && pnpm oxlint --fix --no-error-on-unmatched-pattern {staged_files} && pnpm eslint --cache --fix {staged_files} && pnpm stylelint --fix --allow-empty-input {staged_files}
glob: '*.vue'
lint-js:
run: pnpm oxfmt {staged_files} && pnpm oxlint --fix {staged_files} && pnpm eslint --cache --fix {staged_files}
run: pnpm oxfmt {staged_files} && pnpm oxlint --fix --no-error-on-unmatched-pattern {staged_files} && pnpm eslint --cache --fix {staged_files}
glob: '*.{js,jsx,ts,tsx}'
lint-style:
run: pnpm oxfmt {staged_files} && pnpm stylelint --fix --allow-empty-input {staged_files}

4
package.json

@ -102,8 +102,8 @@
"vue-tsc": "catalog:"
},
"engines": {
"node": "^20.19.0 || ^22.18.0 || ^24.0.0",
"node": "^22.18.0 || ^24.0.0",
"pnpm": ">=10.0.0"
},
"packageManager": "pnpm@10.33.0"
"packageManager": "pnpm@10.33.4"
}

418
packages/@core/base/shared/src/cache/README.md

@ -0,0 +1,418 @@
# Cache 模块
基于**策略模式**的异步存储管理方案,支持多种存储后端(localStorage、IndexedDB、Memory),提供统一的 API 接口。
## 架构设计
```shell
┌───────────────────────────────────────────────┐
│ StorageManager │
│ ┌─────────────┐ ┌───────────────────────┐ │
│ │ Prefix 隔离 │ │ TTL 过期管理 │ │
│ └─────────────┘ └───────────────────────┘ │
├───────────────────────────────────────────────┤
│ IStorageDriver │
├──────────┬─────────────────┬──────────────────┤
│ Local │ IndexedDB │ Memory │
│ Storage │ Driver │ Driver │
│ Driver │ │ │
└──────────┴─────────────────┴──────────────────┘
```
**分层职责:**
| 层级 | 职责 |
| ---------------- | -------------------------------------------- |
| `StorageManager` | 命名空间前缀隔离、TTL 过期检查、统一对外 API |
| `IStorageDriver` | 纯粹的 KV 存取抽象接口 |
| 各 Driver 实现 | 对接具体存储引擎,不感知前缀和 TTL |
---
## 快速开始
### 基本使用(默认 localStorage)
```typescript
import { StorageManager } from '@vben-core/shared/cache';
const cache = new StorageManager({ prefix: 'myapp' });
// 使用 IndexedDB
//new StorageManager({ driver: new IndexedDBDriver(), prefix: 'app' });
// 使用 sessionStorage
//new StorageManager({ driver: new LocalStorageDriver({ storageType: 'sessionStorage' }), prefix: 'app' });
// 测试环境
//new StorageManager({ driver: new MemoryStorageDriver(), prefix: 'test' });
// 存储数据
await cache.setItem('user', { name: '张三', age: 28 });
// 读取数据
const user = await cache.getItem('user');
// => { name: '张三', age: 28 }
// 带默认值读取
const settings = await cache.getItem('settings', { theme: 'light' });
// 如果不存在,返回 { theme: 'light' }
// 删除数据
await cache.removeItem('user');
// 清除当前前缀下所有数据
await cache.clear();
```
### 带 TTL 过期
```typescript
const cache = new StorageManager({ prefix: 'session' });
// 设置 5 分钟后过期(TTL 单位为毫秒)
await cache.setItem('token', 'abc123', 5 * 60 * 1000);
// 5 分钟内可以正常读取
const token = await cache.getItem('token');
// => 'abc123'
// 5 分钟后自动返回 null(惰性删除)
const expiredToken = await cache.getItem('token');
// => null
// 主动清理所有过期项
await cache.clearExpiredItems();
```
---
## 存储驱动
### LocalStorageDriver(默认)
基于浏览器 `localStorage` 或 `sessionStorage`,数据持久化存储。
```typescript
import { LocalStorageDriver, StorageManager } from '@vben-core/shared/cache';
// 使用 localStorage(默认)
const cache = new StorageManager({
driver: new LocalStorageDriver(),
prefix: 'app',
});
// 使用 sessionStorage
const sessionCache = new StorageManager({
driver: new LocalStorageDriver({ storageType: 'sessionStorage' }),
prefix: 'app',
});
```
**特点:**
- 同步 API 用 async 包装,保持接口统一
- 自动处理 JSON 序列化/反序列化
- 数据损坏时自动清除并返回 null
- 存储上限约 5-10MB(视浏览器而定)
**适用场景:** 用户偏好设置、小型配置数据、Token 存储
---
### IndexedDBDriver
基于浏览器 IndexedDB,支持大容量结构化数据存储。
```typescript
import {IndexedDBDriver, StorageManager} from '@vben-core/shared/cache';
const cache = new StorageManager({
driver: new IndexedDBDriver({
dbName: 'my-app-db', // 数据库名称,默认 'vben-storage'
dbVersion: 1, // 数据库版本,默认 1
storeName: 'cache-store', // 对象存储名称,默认 'kv-store'
}),
prefix: 'data',
});
// 存储大量数据
await cache.setItem('table-data', largeDataArray);
// 存储二进制友好的结构(IndexedDB 原生支持)
await cache.setItem('config', {
columns: [...],
filters: [...],
pagination: {page: 1, size: 20},
});
```
**特点:**
- 懒初始化:首次操作时自动打开数据库,无需手动调用 `init()`
- 存储容量大(通常数百 MB 到 GB 级别)
- 支持结构化克隆(可存储 Date、RegExp、Blob 等复杂类型)
- 天然异步,不阻塞主线程
**适用场景:** 离线数据缓存、大型表格数据、文件/图片缓存、复杂业务数据
---
### MemoryStorageDriver
基于内存 Map,数据不持久化,页面刷新即丢失。
```typescript
import { MemoryStorageDriver, StorageManager } from '@vben-core/shared/cache';
const cache = new StorageManager({
driver: new MemoryStorageDriver(),
prefix: 'test',
});
```
**特点:**
- 读写速度最快
- 无浏览器 API 依赖
- 数据随页面生命周期销毁
**适用场景:** 单元测试、SSR 服务端渲染、临时运行时缓存
---
## API 参考
### StorageManager
#### 构造函数
```typescript
new StorageManager(options?: StorageManagerOptions)
```
| 参数 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `driver` | `IStorageDriver` | `new LocalStorageDriver()` | 存储驱动实例 |
| `prefix` | `string` | `''` | 键前缀,用于命名空间隔离 |
#### 方法
| 方法 | 签名 | 说明 |
| --- | --- | --- |
| `getItem` | `getItem<T>(key: string, defaultValue?: T \| null): Promise<T \| null>` | 获取存储项,过期或不存在返回默认值 |
| `setItem` | `setItem<T>(key: string, value: T, ttl?: number): Promise<void>` | 设置存储项,可选 TTL(毫秒) |
| `removeItem` | `removeItem(key: string): Promise<void>` | 删除指定存储项 |
| `clear` | `clear(): Promise<void>` | 清除当前前缀下所有存储项 |
| `clearExpiredItems` | `clearExpiredItems(): Promise<void>` | 主动清理所有过期项 |
---
### IStorageDriver 接口
自定义驱动需要实现此接口:
```typescript
interface IStorageDriver {
clear(): Promise<void>;
getItem<T>(key: string): Promise<null | T>;
keys(): Promise<string[]>;
removeItem(key: string): Promise<void>;
setItem<T>(key: string, value: T): Promise<void>;
}
```
---
## 高级用法
### 自定义 Driver
```typescript
import type { IStorageDriver } from '@vben-core/shared/cache';
class CookieStorageDriver implements IStorageDriver {
async getItem<T>(key: string): Promise<null | T> {
const value = getCookie(key);
return value ? JSON.parse(value) : null;
}
async setItem<T>(key: string, value: T): Promise<void> {
setCookie(key, JSON.stringify(value));
}
async removeItem(key: string): Promise<void> {
deleteCookie(key);
}
async clear(): Promise<void> {
clearAllCookies();
}
async keys(): Promise<string[]> {
return getAllCookieNames();
}
}
// 使用自定义 Driver
const cache = new StorageManager({
driver: new CookieStorageDriver(),
prefix: 'ck',
});
```
### 根据环境动态选择 Driver
```typescript
import {
IndexedDBDriver,
LocalStorageDriver,
MemoryStorageDriver,
StorageManager,
} from '@vben-core/shared/cache';
function createStorageManager(prefix: string) {
// SSR 环境使用内存驱动
if (typeof window === 'undefined') {
return new StorageManager({
driver: new MemoryStorageDriver(),
prefix,
});
}
// 大数据场景使用 IndexedDB
if (needsLargeStorage()) {
return new StorageManager({
driver: new IndexedDBDriver({ dbName: `${prefix}-db` }),
prefix,
});
}
// 默认使用 localStorage
return new StorageManager({ prefix });
}
```
### 命名空间隔离
```typescript
// 不同模块使用不同前缀,互不干扰
const userCache = new StorageManager({ prefix: 'user' });
const configCache = new StorageManager({ prefix: 'config' });
await userCache.setItem('profile', { name: '张三' });
await configCache.setItem('profile', { theme: 'dark' });
// 各自独立
await userCache.getItem('profile'); // => { name: '张三' }
await configCache.getItem('profile'); // => { theme: 'dark' }
// 只清除 user 前缀的数据
await userCache.clear();
await configCache.getItem('profile'); // => { theme: 'dark' }(不受影响)
```
### 定时清理过期数据
```typescript
const cache = new StorageManager({ prefix: 'app' });
// 应用启动时清理一次
await cache.clearExpiredItems();
// 或者定时清理(每 10 分钟)
setInterval(
async () => {
await cache.clearExpiredItems();
},
10 * 60 * 1000,
);
```
---
## 数据存储格式
`StorageManager` 在 Driver 层存储的数据结构为:
```typescript
interface StorageItem<T> {
expiry?: number; // 过期时间戳(毫秒),undefined 表示永不过期
value: T; // 实际业务数据
}
```
实际存储的 key 格式为:`{prefix}-{key}`
例如 `prefix = 'app'`,`key = 'user'`,则实际存储键为 `app-user`。
---
## 过期策略
采用**惰性删除 + 主动清理**双重策略:
| 策略 | 触发时机 | 说明 |
| --- | --- | --- |
| 惰性删除 | 调用 `getItem` 时 | 读取时检查过期,过期则删除并返回默认值 |
| 主动清理 | 调用 `clearExpiredItems` 时 | 遍历所有带前缀的 key,删除已过期项 |
---
## 各 Driver 对比
| 特性 | LocalStorageDriver | IndexedDBDriver | MemoryStorageDriver |
| ---------- | ------------------- | ---------------- | ------------------- |
| 持久化 | ✅ | ✅ | ❌ |
| 容量 | 5-10 MB | 数百 MB+ | 受内存限制 |
| 速度 | 快(同步) | 中等(异步 I/O) | 最快 |
| 数据类型 | 仅 JSON 可序列化 | 结构化克隆 | 任意 JS 对象 |
| 浏览器支持 | 所有现代浏览器 | 所有现代浏览器 | 任意环境 |
| 阻塞主线程 | 是 | 否 | 否 |
| 适用场景 | 配置、Token、小数据 | 离线缓存、大数据 | 测试、SSR |
---
## 在项目中的使用
本项目中 `StorageManager` 主要被 `PreferenceManager` 消费,用于持久化用户偏好设置:
```typescript
// packages/@core/preferences/src/preferences.ts
class PreferenceManager {
private cache: StorageManager;
constructor() {
this.cache = new StorageManager();
this.state = reactive<Preferences>({ ...defaultPreferences });
}
initPreferences = async ({ namespace }) => {
// 用应用命名空间重新初始化
this.cache = new StorageManager({ prefix: namespace });
// 从缓存加载偏好设置
const cached = await this.cache.getItem<Preferences>('preferences');
// ...
};
}
```
---
## 注意事项
1. **所有方法都是异步的** — 即使底层是同步的 localStorage,API 也返回 Promise,确保切换 Driver 时无需改动调用方。
2. **TTL 单位是毫秒** — `setItem('key', value, 60000)` 表示 60 秒后过期。
3. **IndexedDB 懒初始化** — 不需要手动调用 `init()` 或 `open()`,首次操作时自动打开数据库连接并复用。
4. **前缀隔离是逻辑隔离** — `clear()` 只清除当前前缀下的数据,不影响其他前缀或无前缀的数据。
5. **错误处理** — LocalStorageDriver 在 JSON 解析失败时自动清除损坏数据; `PreferenceManager.saveToCache` 内部 try-catch 防止未捕获异常。
6. **IndexedDB 版本升级** — 如果需要修改 objectStore 结构,需要递增 `dbVersion`。当前实现在 `upgradeneeded` 事件中自动创建 objectStore。

119
packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryStorageDriver } from '../memory-storage-driver';
import { StorageManager } from '../storage-manager';
describe('storageManager', () => {
@ -7,123 +8,115 @@ describe('storageManager', () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
storageManager = new StorageManager({
driver: new MemoryStorageDriver(),
prefix: 'test_',
});
});
it('should set and get an item', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
const user = storageManager.getItem('user');
it('should set and get an item', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' });
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should return default value if item does not exist', () => {
const user = storageManager.getItem('nonexistent', {
it('should return default value if item does not exist', async () => {
const user = await storageManager.getItem('nonexistent', {
age: 0,
name: 'Default User',
});
expect(user).toEqual({ age: 0, name: 'Default User' });
});
it('should remove an item', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
storageManager.removeItem('user');
const user = storageManager.getItem('user');
it('should remove an item', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' });
await storageManager.removeItem('user');
const user = await storageManager.getItem('user');
expect(user).toBeNull();
});
it('should clear all items with the prefix', () => {
storageManager.setItem('user1', { age: 30, name: 'John Doe' });
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' });
storageManager.clear();
expect(storageManager.getItem('user1')).toBeNull();
expect(storageManager.getItem('user2')).toBeNull();
it('should clear all items with the prefix', async () => {
await storageManager.setItem('user1', { age: 30, name: 'John Doe' });
await storageManager.setItem('user2', { age: 25, name: 'Jane Doe' });
await storageManager.clear();
expect(await storageManager.getItem('user1')).toBeNull();
expect(await storageManager.getItem('user2')).toBeNull();
});
it('should clear expired items', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
it('should clear expired items', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
vi.advanceTimersByTime(1001); // 快进时间
storageManager.clearExpiredItems();
const user = storageManager.getItem('user');
await storageManager.clearExpiredItems();
const user = await storageManager.getItem('user');
expect(user).toBeNull();
});
it('should not clear non-expired items', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
it('should not clear non-expired items', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
vi.advanceTimersByTime(5000); // 快进时间
storageManager.clearExpiredItems();
const user = storageManager.getItem('user');
await storageManager.clearExpiredItems();
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should handle JSON parse errors gracefully', () => {
localStorage.setItem('test_user', '{ invalid JSON }');
const user = storageManager.getItem('user', {
age: 0,
name: 'Default User',
});
expect(user).toEqual({ age: 0, name: 'Default User' });
});
it('should return null for non-existent items without default value', () => {
const user = storageManager.getItem('nonexistent');
it('should return null for non-existent items without default value', async () => {
const user = await storageManager.getItem('nonexistent');
expect(user).toBeNull();
});
it('should overwrite existing items', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
storageManager.setItem('user', { age: 25, name: 'Jane Doe' });
const user = storageManager.getItem('user');
it('should overwrite existing items', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' });
await storageManager.setItem('user', { age: 25, name: 'Jane Doe' });
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 25, name: 'Jane Doe' });
});
it('should handle items without expiry correctly', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
it('should handle items without expiry correctly', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' });
vi.advanceTimersByTime(5000);
const user = storageManager.getItem('user');
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should remove expired items when accessed', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
it('should remove expired items when accessed', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
vi.advanceTimersByTime(1001); // 快进时间
const user = storageManager.getItem('user');
const user = await storageManager.getItem('user');
expect(user).toBeNull();
});
it('should not remove non-expired items when accessed', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
it('should not remove non-expired items when accessed', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
vi.advanceTimersByTime(5000); // 快进时间
const user = storageManager.getItem('user');
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should handle multiple items with different expiry times', () => {
storageManager.setItem('user1', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }, 2000); // 2秒过期
it('should handle multiple items with different expiry times', async () => {
await storageManager.setItem('user1', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
await storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }, 2000); // 2秒过期
vi.advanceTimersByTime(1500); // 快进时间
storageManager.clearExpiredItems();
const user1 = storageManager.getItem('user1');
const user2 = storageManager.getItem('user2');
await storageManager.clearExpiredItems();
const user1 = await storageManager.getItem('user1');
const user2 = await storageManager.getItem('user2');
expect(user1).toBeNull();
expect(user2).toEqual({ age: 25, name: 'Jane Doe' });
});
it('should handle items with no expiry', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
it('should handle items with no expiry', async () => {
await storageManager.setItem('user', { age: 30, name: 'John Doe' });
vi.advanceTimersByTime(10_000); // 快进时间
storageManager.clearExpiredItems();
const user = storageManager.getItem('user');
await storageManager.clearExpiredItems();
const user = await storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should clear all items correctly', () => {
storageManager.setItem('user1', { age: 30, name: 'John Doe' });
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' });
storageManager.clear();
const user1 = storageManager.getItem('user1');
const user2 = storageManager.getItem('user2');
it('should clear all items correctly', async () => {
await storageManager.setItem('user1', { age: 30, name: 'John Doe' });
await storageManager.setItem('user2', { age: 25, name: 'Jane Doe' });
await storageManager.clear();
const user1 = await storageManager.getItem('user1');
const user2 = await storageManager.getItem('user2');
expect(user1).toBeNull();
expect(user2).toBeNull();
});

4
packages/@core/base/shared/src/cache/index.ts

@ -1 +1,5 @@
export * from './indexeddb-driver';
export * from './local-storage-driver';
export * from './memory-storage-driver';
export * from './storage-manager';
export type * from './types';

137
packages/@core/base/shared/src/cache/indexeddb-driver.ts

@ -0,0 +1,137 @@
import type { IStorageDriver } from './types';
interface IndexedDBDriverOptions {
/** 数据库名称 */
dbName?: string;
/** 数据库版本 */
dbVersion?: number;
/** 对象存储名称 */
storeName?: string;
}
/**
* IndexedDB 驱动
* 采用懒初始化模式,首次操作时自动打开数据库
*/
class IndexedDBDriver implements IStorageDriver {
private dbName: string;
private dbPromise: null | Promise<IDBDatabase> = null;
private dbVersion: number;
private storeName: string;
constructor({
dbName = 'vben-storage',
dbVersion = 1,
storeName = 'kv-store',
}: IndexedDBDriverOptions = {}) {
this.dbName = dbName;
this.dbVersion = dbVersion;
this.storeName = storeName;
}
async clear(): Promise<void> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
store.clear();
tx.addEventListener('complete', () => resolve());
tx.addEventListener('error', () => reject(tx.error));
tx.addEventListener('abort', () =>
reject(tx.error ?? new Error('Transaction aborted')),
);
});
}
async getItem<T>(key: string): Promise<null | T> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const request = store.get(key);
request.addEventListener('success', () =>
resolve(request.result ?? null),
);
request.addEventListener('error', () => reject(request.error));
});
}
async keys(): Promise<string[]> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const request = store.getAllKeys();
request.addEventListener('success', () =>
resolve(request.result.map(String)),
);
request.addEventListener('error', () => reject(request.error));
});
}
async removeItem(key: string): Promise<void> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
store.delete(key);
tx.addEventListener('complete', () => resolve());
tx.addEventListener('error', () => reject(tx.error));
tx.addEventListener('abort', () =>
reject(tx.error ?? new Error('Transaction aborted')),
);
});
}
async setItem(key: string, value: unknown): Promise<void> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
store.put(value, key);
tx.addEventListener('complete', () => resolve());
tx.addEventListener('error', () => reject(tx.error));
tx.addEventListener('abort', () =>
reject(tx.error ?? new Error('Transaction aborted')),
);
});
}
/**
* 懒初始化:首次调用时打开数据库,后续复用同一个 Promise
*/
private getDB(): Promise<IDBDatabase> {
if (!this.dbPromise) {
this.dbPromise = this.openDB().catch((error) => {
// allow retry on next call
this.dbPromise = null;
throw error;
});
}
return this.dbPromise;
}
private openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.addEventListener('upgradeneeded', () => {
const db = request.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName);
}
});
request.addEventListener('success', () => resolve(request.result));
request.addEventListener('error', () => reject(request.error));
});
}
}
export { IndexedDBDriver };
export type { IndexedDBDriverOptions };

71
packages/@core/base/shared/src/cache/local-storage-driver.ts

@ -0,0 +1,71 @@
import type { IStorageDriver } from './types';
type StorageType = 'localStorage' | 'sessionStorage';
interface LocalStorageDriverOptions {
/** 使用 localStorage 还是 sessionStorage */
storageType?: StorageType;
}
/**
* LocalStorage / SessionStorage 驱动
* 用 async 包装同步 API,保持接口统一
*/
class LocalStorageDriver implements IStorageDriver {
private storage: Storage;
constructor({
storageType = 'localStorage',
}: LocalStorageDriverOptions = {}) {
if (typeof window === 'undefined') {
// eslint-disable-next-line unicorn/prefer-type-error -- not a type check, it's an environment check
throw new Error(
'LocalStorageDriver is not available in non-browser environments. Use MemoryStorageDriver instead.',
);
}
this.storage =
storageType === 'localStorage'
? window.localStorage
: window.sessionStorage;
}
async clear(): Promise<void> {
this.storage.clear();
}
async getItem<T>(key: string): Promise<null | T> {
const raw = this.storage.getItem(key);
if (raw === null) {
return null;
}
try {
return JSON.parse(raw) as T;
} catch {
// 数据损坏,清除并返回 null
this.storage.removeItem(key);
return null;
}
}
async keys(): Promise<string[]> {
const result: string[] = [];
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key !== null) {
result.push(key);
}
}
return result;
}
async removeItem(key: string): Promise<void> {
this.storage.removeItem(key);
}
async setItem(key: string, value: unknown): Promise<void> {
this.storage.setItem(key, JSON.stringify(value));
}
}
export { LocalStorageDriver };
export type { LocalStorageDriverOptions };

32
packages/@core/base/shared/src/cache/memory-storage-driver.ts

@ -0,0 +1,32 @@
import type { IStorageDriver } from './types';
/**
* 内存存储驱动
* 适用于测试环境和 SSR 场景,数据不持久化
*/
class MemoryStorageDriver implements IStorageDriver {
private store = new Map<string, unknown>();
async clear(): Promise<void> {
this.store.clear();
}
async getItem<T>(key: string): Promise<null | T> {
const value = this.store.get(key);
return (value as T) ?? null;
}
async keys(): Promise<string[]> {
return [...this.store.keys()];
}
async removeItem(key: string): Promise<void> {
this.store.delete(key);
}
async setItem(key: string, value: unknown): Promise<void> {
this.store.set(key, value);
}
}
export { MemoryStorageDriver };

140
packages/@core/base/shared/src/cache/storage-manager.ts

@ -1,53 +1,54 @@
type StorageType = 'localStorage' | 'sessionStorage';
import type {
IStorageDriver,
StorageItem,
StorageManagerOptions,
} from './types';
interface StorageManagerOptions {
prefix?: string;
storageType?: StorageType;
}
interface StorageItem<T> {
expiry?: number;
value: T;
}
import { LocalStorageDriver } from './local-storage-driver';
import { MemoryStorageDriver } from './memory-storage-driver';
/**
* 存储管理器(策略模式)
* - prefix(命名空间隔离)在此层处理
* - TTL(过期机制)在此层处理
* - Driver 只负责纯粹的 KV 存取
*/
class StorageManager {
private driver: IStorageDriver;
private prefix: string;
private storage: Storage;
constructor({
prefix = '',
storageType = 'localStorage',
}: StorageManagerOptions = {}) {
constructor({ driver, prefix = '' }: StorageManagerOptions = {}) {
this.driver = driver || this.createDefaultDriver();
this.prefix = prefix;
this.storage =
storageType === 'localStorage'
? window.localStorage
: window.sessionStorage;
if (!this.prefix && this.driver instanceof LocalStorageDriver) {
console.warn(
'[StorageManager] empty prefix combined with LocalStorageDriver — clear()/keys() will affect every localStorage entry.',
);
}
}
/**
* 清除所有带前缀的存储项
*/
clear(): void {
const keysToRemove: string[] = [];
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key && key.startsWith(this.prefix)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => this.storage.removeItem(key));
async clear(): Promise<void> {
const allKeys = await this.driver.keys();
const fullPrefix = this.prefix ? `${this.prefix}-` : '';
const prefixedKeys = allKeys.filter((key) => key.startsWith(fullPrefix));
await Promise.all(prefixedKeys.map((key) => this.driver.removeItem(key)));
}
/**
* 清除所有过期的存储项
*/
clearExpiredItems(): void {
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key && key.startsWith(this.prefix)) {
const shortKey = key.replace(this.prefix, '');
this.getItem(shortKey); // 调用 getItem 方法检查并移除过期项
async clearExpiredItems(): Promise<void> {
const allKeys = await this.driver.keys();
const fullPrefix = this.prefix ? `${this.prefix}-` : '';
const prefixedKeys = allKeys.filter((key) => key.startsWith(fullPrefix));
for (const fullKey of prefixedKeys) {
const raw = await this.driver.getItem<StorageItem<unknown>>(fullKey);
if (raw && raw.expiry && Date.now() > raw.expiry) {
await this.driver.removeItem(fullKey);
}
}
}
@ -56,36 +57,47 @@ class StorageManager {
* 获取存储项
* @param key 键
* @param defaultValue 当项不存在或已过期时返回的默认值
* @returns 值,如果项已过期或解析错误则返回默认值
* @returns 值,如果项已过期则返回默认值
*/
getItem<T>(key: string, defaultValue: null | T = null): null | T {
async getItem<T>(
key: string,
defaultValue: null | T = null,
): Promise<null | T> {
const fullKey = this.getFullKey(key);
const itemStr = this.storage.getItem(fullKey);
if (!itemStr) {
const raw = await this.driver.getItem<StorageItem<T>>(fullKey);
if (!raw) {
return defaultValue;
}
try {
const item: StorageItem<T> = JSON.parse(itemStr);
if (item.expiry && Date.now() > item.expiry) {
this.storage.removeItem(fullKey);
return defaultValue;
}
return item.value;
} catch (error) {
console.error(`Error parsing item with key "${fullKey}":`, error);
this.storage.removeItem(fullKey); // 如果解析失败,删除该项
// TTL 检查
if (raw.expiry && Date.now() > raw.expiry) {
await this.driver.removeItem(fullKey);
return defaultValue;
}
return raw.value;
}
/**
* 获取当前前缀下的所有存储键(已去除前缀部分)
*/
async keys(): Promise<string[]> {
const allKeys = await this.driver.keys();
const fullPrefix = this.prefix ? `${this.prefix}-` : '';
if (!fullPrefix) return allKeys;
return allKeys
.filter((key) => key.startsWith(fullPrefix))
.map((key) => key.slice(fullPrefix.length));
}
/**
* 移除存储项
* @param key 键
*/
removeItem(key: string): void {
async removeItem(key: string): Promise<void> {
const fullKey = this.getFullKey(key);
this.storage.removeItem(fullKey);
await this.driver.removeItem(fullKey);
}
/**
@ -94,24 +106,40 @@ class StorageManager {
* @param value 值
* @param ttl 存活时间(毫秒)
*/
setItem<T>(key: string, value: T, ttl?: number): void {
async setItem(key: string, value: unknown, ttl?: number): Promise<void> {
const fullKey = this.getFullKey(key);
const expiry = ttl ? Date.now() + ttl : undefined;
const item: StorageItem<T> = { expiry, value };
const item: StorageItem<unknown> = { expiry, value };
await this.driver.setItem(fullKey, item);
}
/**
* 根据运行环境创建默认驱动:
* - 浏览器环境(window.localStorage 可用)→ LocalStorageDriver
* - SSR / Node 环境 → MemoryStorageDriver
*/
private createDefaultDriver(): IStorageDriver {
try {
this.storage.setItem(fullKey, JSON.stringify(item));
if (typeof window !== 'undefined' && window.localStorage) {
return new LocalStorageDriver();
}
} catch (error) {
console.error(`Error setting item with key "${fullKey}":`, error);
// localStorage access denied (e.g. Safari private mode)
console.warn(
'localStorage is not accessible, falling back to MemoryStorageDriver:',
error,
);
}
return new MemoryStorageDriver();
}
/**
* 获取完整的存储键
* 获取完整的存储键(带前缀)
* @param key 原始键
* @returns 带前缀的完整键
*/
private getFullKey(key: string): string {
return `${this.prefix}-${key}`;
return this.prefix ? `${this.prefix}-${key}` : key;
}
}

46
packages/@core/base/shared/src/cache/types.ts

@ -1,17 +1,39 @@
type StorageType = 'localStorage' | 'sessionStorage';
/**
* 存储驱动接口(策略模式核心抽象)
* 所有存储实现(localStorage、IndexedDB、Memory 等)都需要实现此接口
* Driver 层只负责纯粹的 KV 存取,不感知 TTL 和前缀
*/
interface IStorageDriver {
/** 清除所有存储项 */
clear(): Promise<void>;
interface StorageValue<T> {
data: T;
expiry: null | number;
/** 获取存储项 */
getItem<T>(key: string): Promise<null | T>;
/** 获取所有 key */
keys(): Promise<string[]>;
/** 移除存储项 */
removeItem(key: string): Promise<void>;
/** 设置存储项 */
setItem(key: string, value: unknown): Promise<void>;
}
/**
* 带 TTL 的存储项包装结构
* TTL 逻辑由 StorageManager 统一管理,Driver 层不感知
*/
interface StorageItem<T> {
expiry?: number;
value: T;
}
interface IStorageCache {
clear(): void;
getItem<T>(key: string): null | T;
key(index: number): null | string;
length(): number;
removeItem(key: string): void;
setItem<T>(key: string, value: T, expiryInMinutes?: number): void;
interface StorageManagerOptions {
/** 存储驱动实例 */
driver?: IStorageDriver;
/** 键前缀,用于命名空间隔离 */
prefix?: string;
}
export type { IStorageCache, StorageType, StorageValue };
export type { IStorageDriver, StorageItem, StorageManagerOptions };

4
packages/@core/base/shared/src/utils/__tests__/state-handler.test.ts

@ -34,7 +34,9 @@ describe('stateHandler', () => {
}, 10);
// 等待过程中,期望 Promise 被 reject
await expect(handler.waitForCondition()).rejects.toThrow();
await expect(handler.waitForCondition()).rejects.toThrow(
'Condition was set to false',
);
expect(handler.isConditionTrue()).toBe(false);
});

6
packages/@core/base/shared/src/utils/__tests__/util.test.ts

@ -138,8 +138,10 @@ describe('getNestedValue', () => {
expect(result).toBe(2);
});
it('should return the entire object if path is empty', () => {
expect(() => getNestedValue(data, '')()).toThrow();
it('should throw if path is empty', () => {
expect(() => getNestedValue(data, '')).toThrow(
'Path must be a non-empty string',
);
});
it('should handle paths with array indexes', () => {

3
packages/@core/base/shared/src/utils/date.ts

@ -20,6 +20,9 @@ type Format =
| (string & {});
export function formatDate(time?: FormatDate, format: Format = 'YYYY-MM-DD') {
if (time === undefined || time === null || time === '') {
return '';
}
try {
const date = dayjs.isDayjs(time) ? time : dayjs(time);
if (!date.isValid()) {

4
packages/@core/base/shared/src/utils/state-handler.ts

@ -1,6 +1,6 @@
export class StateHandler {
private condition: boolean = false;
private rejectCondition: (() => void) | null = null;
private rejectCondition: ((reason?: Error) => void) | null = null;
private resolveCondition: (() => void) | null = null;
isConditionTrue(): boolean {
@ -16,7 +16,7 @@ export class StateHandler {
setConditionFalse() {
this.condition = false;
if (this.rejectCondition) {
this.rejectCondition();
this.rejectCondition(new Error('Condition was set to false'));
this.clearPromises();
}
}

10
packages/@core/preferences/__tests__/preferences.test.ts

@ -105,7 +105,7 @@ describe('preferences', () => {
expect(preferenceManager.getPreferences().app.colorWeakMode).toBe(true);
});
it('resets preferences to default', () => {
it('resets preferences to default', async () => {
// 先更新一些偏好设置
preferenceManager.updatePreferences({
theme: {
@ -114,7 +114,7 @@ describe('preferences', () => {
});
// 然后重置偏好设置
preferenceManager.resetPreferences();
await preferenceManager.resetPreferences();
expect(preferenceManager.getPreferences()).toEqual(defaultPreferences);
});
@ -174,7 +174,7 @@ describe('preferences', () => {
);
});
it('resets preferences to default correctly', () => {
it('resets preferences to default correctly', async () => {
// 先更新一些偏好设置
preferenceManager.updatePreferences({
app: { locale: 'en-US' },
@ -185,7 +185,7 @@ describe('preferences', () => {
});
// 然后重置偏好设置
preferenceManager.resetPreferences();
await preferenceManager.resetPreferences();
expect(preferenceManager.getPreferences()).toEqual(defaultPreferences);
});
@ -377,7 +377,7 @@ describe('preferences', () => {
reportTitle: '月报',
});
preferenceManager.resetPreferences();
await preferenceManager.resetPreferences();
expect(preferenceManager.getCustomPreferences()).toEqual({
pageSize: 20,

60
packages/@core/preferences/src/preferences.ts

@ -41,17 +41,19 @@ class PreferenceManager {
constructor() {
this.cache = new StorageManager();
this.state = reactive<Preferences>(
this.loadFromCache() || { ...defaultPreferences },
);
// 构造函数不再同步读取缓存,使用默认值初始化
// 真正的缓存加载在 initPreferences 中完成(已经是 async)
this.state = reactive<Preferences>({ ...defaultPreferences });
this.debouncedSave = useDebounceFn(() => this.saveToCache(), 150);
}
/**
* 清除所有缓存的偏好设置
*/
clearCache = () => {
Object.values(STORAGE_KEYS).forEach((key) => this.cache.removeItem(key));
clearCache = async () => {
await Promise.all(
Object.values(STORAGE_KEYS).map((key) => this.cache.removeItem(key)),
);
};
/**
@ -130,7 +132,7 @@ class PreferenceManager {
);
// 加载缓存的偏好设置并与初始配置合并
const cachedPreferences = this.loadFromCache() || {};
const cachedPreferences = (await this.loadFromCache()) || {};
const mergedPreference = merge(
{},
cachedPreferences,
@ -139,14 +141,16 @@ class PreferenceManager {
// 更新偏好设置
this.updatePreferences(mergedPreference);
const cachedCustom = (await this.loadCustomFromCache()) || {};
this.replaceCustomPreferences(
merge(
{},
this.sanitizeCustomPreferences(this.loadCustomFromCache() || {}),
this.sanitizeCustomPreferences(cachedCustom),
this.initialCustomPreferences,
),
);
this.saveToCache();
await this.saveToCache();
// 设置监听器
this.setupWatcher();
@ -160,13 +164,13 @@ class PreferenceManager {
/**
* 重置偏好设置到初始状态
*/
resetPreferences = () => {
resetPreferences = async () => {
// 将状态重置为初始偏好设置
Object.assign(this.state, this.initialPreferences);
this.replaceCustomPreferences(this.initialCustomPreferences);
// 保存偏好设置至缓存
this.saveToCache();
await this.saveToCache();
// 直接触发 UI 更新
this.handleUpdates(this.state);
@ -176,11 +180,7 @@ class PreferenceManager {
* 更新扩展偏好设置
* @param updates - 要更新的扩展偏好设置
*/
updateCustomPreferences = <
TCustomPreferences extends object = CustomPreferencesRecord,
>(
updates: DeepPartial<TCustomPreferences>,
) => {
updateCustomPreferences = (updates: DeepPartial<object>) => {
if (!this.customPreferencesExtension) {
return;
}
@ -211,7 +211,7 @@ class PreferenceManager {
// 根据更新的值执行更新
this.handleUpdates(updates);
// 保存到缓存
// 保存到缓存(fire-and-forget,通过 debounce 控制频率)
this.debouncedSave();
};
@ -320,7 +320,7 @@ class PreferenceManager {
* 从缓存加载扩展偏好设置
* @returns 缓存的扩展偏好设置,如果不存在则返回 null
*/
private loadCustomFromCache(): CustomPreferencesRecord | null {
private async loadCustomFromCache(): Promise<CustomPreferencesRecord | null> {
return this.cache.getItem<CustomPreferencesRecord>(STORAGE_KEYS.CUSTOM);
}
@ -328,7 +328,7 @@ class PreferenceManager {
* 从缓存加载偏好设置
* @returns 缓存的偏好设置,如果不存在则返回 null
*/
private loadFromCache(): null | Preferences {
private async loadFromCache(): Promise<null | Preferences> {
return this.cache.getItem<Preferences>(STORAGE_KEYS.MAIN);
}
@ -387,17 +387,23 @@ class PreferenceManager {
/**
* 保存偏好设置到缓存
*/
private saveToCache() {
this.cache.setItem(STORAGE_KEYS.MAIN, this.state);
this.cache.setItem(STORAGE_KEYS.LOCALE, this.state.app.locale);
this.cache.setItem(STORAGE_KEYS.THEME, this.state.theme.mode);
private async saveToCache() {
try {
await this.cache.setItem(STORAGE_KEYS.MAIN, this.state);
await this.cache.setItem(STORAGE_KEYS.LOCALE, this.state.app.locale);
await this.cache.setItem(STORAGE_KEYS.THEME, this.state.theme.mode);
if (this.customPreferencesExtension) {
await this.cache.setItem(STORAGE_KEYS.CUSTOM, {
...this.customState,
});
return;
}
if (this.customPreferencesExtension) {
this.cache.setItem(STORAGE_KEYS.CUSTOM, { ...this.customState });
return;
await this.cache.removeItem(STORAGE_KEYS.CUSTOM);
} catch (error) {
console.error('Failed to save preferences to cache:', error);
}
this.cache.removeItem(STORAGE_KEYS.CUSTOM);
}
/**

2
packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts

@ -154,7 +154,7 @@ export default function useDependencies(
}
if (isFunction(trigger)) {
trigger(formValues, formApi, getController());
await trigger(formValues, formApi, getController());
}
},
{ deep: true, immediate: true },

2
packages/@core/ui-kit/menu-ui/src/hooks/use-menu-scroll.ts

@ -20,7 +20,7 @@ export function useMenuScroll(
if (!isEnabled) return;
const activeElement = document.querySelector(
`aside li[role=menuitem].is-active`,
`aside a[role=menuitem].is-active`,
);
if (activeElement) {
activeElement.scrollIntoView({

34
packages/@core/ui-kit/shadcn-ui/src/components/context-menu/context-menu.vue

@ -9,7 +9,7 @@ import type { ClassType } from '@vben-core/typings';
import type { IContextMenuItem } from './interface';
import { computed } from 'vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useForwardPropsEmits } from 'reka-ui';
@ -35,6 +35,14 @@ const props = defineProps<
const emits = defineEmits<ContextMenuRootEmits>();
const NATIVE_CONTEXT_SELECTORS = [
'input',
'textarea',
'select',
'[contenteditable]:not([contenteditable="false"])',
'.allow-native-context',
].join(', ');
const delegatedProps = computed(() => {
const {
class: _cls,
@ -59,12 +67,34 @@ function handleClick(menu: IContextMenuItem) {
}
menu?.handler?.(props.handlerData);
}
const triggerRef = ref<HTMLElement | null>(null);
function onContextMenuCapture(e: MouseEvent) {
if ((e.target as HTMLElement).closest(NATIVE_CONTEXT_SELECTORS)) {
e.stopPropagation();
}
}
onMounted(() => {
triggerRef.value?.addEventListener('contextmenu', onContextMenuCapture, {
capture: true,
});
});
onUnmounted(() => {
triggerRef.value?.removeEventListener('contextmenu', onContextMenuCapture, {
capture: true,
});
});
</script>
<template>
<ContextMenu v-bind="forwarded">
<ContextMenuTrigger as-child>
<slot></slot>
<div ref="triggerRef" class="contents">
<slot></slot>
</div>
</ContextMenuTrigger>
<ContextMenuContent
:class="contentClass"

6
packages/@core/ui-kit/shadcn-ui/src/ui/input/Input.vue

@ -24,7 +24,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {
v-model="modelValue"
:class="
cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground/50 focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
'border-input bg-background ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@ -34,6 +34,10 @@ const modelValue = useVModel(props, 'modelValue', emits, {
input {
--ring: var(--primary);
&:focus-visible {
box-shadow: inset 0 0 0 1px hsl(var(--ring));
}
&::-ms-reveal,
&::-ms-clear {
display: none;

2
packages/@core/ui-kit/shadcn-ui/src/ui/select/SelectTrigger.vue

@ -24,7 +24,7 @@ const forwardedProps = useForwardProps(delegatedProps);
v-bind="forwardedProps"
:class="
cn(
'border-input ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
'border-input ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:shadow-[inset_0_0_0_1px_hsl(var(--primary))] flex h-10 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
props.class,
)
"

72
packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue

@ -6,7 +6,7 @@ import type { ClassType, Recordable } from '@vben-core/typings';
import type { TreeProps } from './types';
import { onMounted, ref, watchEffect } from 'vue';
import { computed, onMounted, ref, watchEffect } from 'vue';
import { ChevronRight, IconifyIcon } from '@vben-core/icons';
import { cn, get } from '@vben-core/shared/utils';
@ -70,7 +70,9 @@ let lastTreeData: any = null;
onMounted(() => {
watchEffect(() => {
flattenData.value = flatten(props.treeData, props.childrenField);
updateTreeValue();
if (flattenData.value.length > 0) {
updateTreeValue();
}
// 只在 treeData 变化时执行展开
const currentTreeData = JSON.stringify(props.treeData);
@ -190,6 +192,32 @@ function isNodeDisabled(item: FlattenedItem<Recordable<any>>) {
return props.disabled || get(item.value, props.disabledField);
}
// 计算全选/半选状态
const selectAllStatus = computed<'indeterminate' | boolean>(() => {
if (!props.multiple) return false;
if (!modelValue.value || !Array.isArray(modelValue.value)) return false;
const allValues = flattenData.value
.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;
if (selectedCount === 0) return false;
if (selectedCount === allValues.length) return true;
return 'indeterminate';
});
function onSelectAllChange(checked: 'indeterminate' | boolean) {
if (checked === true) {
checkAll();
} else {
unCheckAll();
}
}
function onToggle(item: FlattenedItem<Recordable<any>>) {
emits('expand', item);
}
@ -314,14 +342,16 @@ defineExpose({
:class="{ 'rotate-90': expanded?.length > 0 }"
class="text-foreground/80 hover:text-foreground size-4 cursor-pointer transition"
/>
<Checkbox
v-if="multiple"
@click.stop
@update:model-value="
(checked: boolean | 'indeterminate') =>
checked === true ? checkAll() : unCheckAll()
"
/>
<div class="flex items-center gap-1 item-all-checkbox">
<Checkbox
v-if="multiple"
:model-value="selectAllStatus"
:indeterminate="selectAllStatus === 'indeterminate'"
@click.stop
@update:model-value="onSelectAllChange"
/>
<span v-if="selectAllLabel">{{ selectAllLabel }}</span>
</div>
</div>
</div>
<TransitionGroup :name="transition ? 'fade' : ''">
@ -369,8 +399,9 @@ defineExpose({
!isNodeDisabled(item) && onToggle(item);
}
"
class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded p-1 outline-hidden focus:ring-2"
class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded p-1 outline-hidden"
>
<!-- class="hover:ring-2" 鼠标移动上去时2px的圆环边框 -->
<ChevronRight
v-if="
item.hasChildren &&
@ -387,7 +418,7 @@ defineExpose({
"
/>
<div v-else class="h-4 w-4"></div>
<div class="flex items-center gap-1">
<div class="flex items-center gap-1 item-checkbox">
<Checkbox
v-if="multiple"
:model-value="isSelected && !isNodeDisabled(item)"
@ -405,7 +436,8 @@ defineExpose({
"
/>
<div
class="flex items-center gap-1"
class="flex items-center gap-1 item-checkbox"
:title="get(item.value, labelField)"
@click="
(event: MouseEvent) => {
if (isNodeDisabled(item)) {
@ -455,6 +487,20 @@ defineExpose({
border: 1px solid #666;
}
.item-checkbox {
width: 100%;
overflow: hidden;
}
.item-all-checkbox {
width: 100%;
overflow: hidden;
.text-label {
margin-left: 8px;
}
}
/* 1. 声明过渡效果 */
.fade-move,
.fade-enter-active,

2
packages/@core/ui-kit/shadcn-ui/src/ui/tree/types.ts

@ -31,6 +31,8 @@ export interface TreeProps {
labelField?: string;
/** 是否多选 */
multiple?: boolean;
/** 选择全部时的文字 */
selectAllLabel?: string;
/** 显示由iconField指定的图标 */
showIcon?: boolean;
/** 启用展开收缩动画 */

3
packages/effects/common-ui/src/components/icon-picker/icon-picker.vue

@ -147,6 +147,9 @@ const searchInputProps = computed(() => {
function updateCurrentSelect(v: string) {
currentSelect.value = v;
if (props.modelValueProp === 'modelValue') {
modelValue.value = v;
}
const eventKey = `onUpdate:${props.modelValueProp}`;
if (attrs[eventKey] && isFunction(attrs[eventKey])) {
attrs[eventKey](v);

12
packages/effects/common-ui/src/components/page/page.vue

@ -12,8 +12,11 @@ defineOptions({
name: 'Page',
});
const { autoContentHeight = false, heightOffset = 0 } =
defineProps<PageProps>();
const {
autoContentHeight = false,
heightOffset = 0,
footerFixed = false,
} = defineProps<PageProps>();
const headerHeight = ref(0);
const footerHeight = ref(0);
@ -36,9 +39,12 @@ async function calcContentHeight() {
if (!autoContentHeight) {
return;
}
shouldAutoHeight.value = false;
await nextTick();
headerHeight.value = headerRef.value?.offsetHeight || 0;
footerHeight.value = footerRef.value?.offsetHeight || 0;
footerHeight.value = footerFixed ? 0 : footerRef.value?.offsetHeight || 0;
setTimeout(() => {
shouldAutoHeight.value = true;
}, 30);

6
packages/effects/common-ui/src/components/page/types.ts

@ -14,4 +14,10 @@ export interface PageProps {
* @default 0
*/
heightOffset?: number;
/**
* Whether the footer is position: fixed.
* When true, footer height is excluded from content height calculation.
* @default false
*/
footerFixed?: boolean;
}

2
packages/effects/hooks/src/use-tabs.ts

@ -47,7 +47,7 @@ export function useTabs() {
}
async function openTabInNewWindow(tab?: RouteLocationNormalized) {
await tabbarStore.openTabInNewWindow(tab || route);
await tabbarStore.openTabInNewWindow(tab || route, router);
}
async function closeTabByKey(key: string) {

12
packages/effects/layouts/src/basic/layout.vue

@ -43,6 +43,7 @@ const {
isHeaderNav,
isMixedNav,
isMobile,
isSideMode,
isSideMixedNav,
isHeaderMixedNav,
isHeaderSidebarNav,
@ -108,6 +109,15 @@ const showHeaderNav = computed(() => {
);
});
const logoTheme = computed(() => {
const showLogoInHeader =
!isSideMode.value ||
isHeaderSidebarNav.value ||
isMixedNav.value ||
isMobile.value;
return showLogoInHeader ? headerTheme.value : sidebarTheme.value;
});
const {
handleMenuSelect,
handleMenuOpen,
@ -282,7 +292,7 @@ const headerSlots = computed(() => {
:src="preferences.logo.source"
:src-dark="preferences.logo.sourceDark"
:text="preferences.app.name"
:theme="showHeaderNav ? headerTheme : theme"
:theme="logoTheme"
@click="clickLogo"
>
<template v-if="$slots['logo-text']" #text>

15
packages/effects/layouts/src/widgets/global-search/search-panel.vue

@ -11,7 +11,12 @@ import { mapTree, traverseTreeValues, uniqueByField } from '@vben/utils';
import { VbenIcon, VbenScrollbar } from '@vben-core/shadcn-ui';
import { isHttpUrl } from '@vben-core/shared/utils';
import { onKeyStroke, useLocalStorage, useThrottleFn } from '@vueuse/core';
import {
onKeyStroke,
useEventListener,
useLocalStorage,
useThrottleFn,
} from '@vueuse/core';
defineOptions({
name: 'SearchPanel',
@ -34,6 +39,7 @@ const searchHistory = useLocalStorage<MenuRecordRaw[]>(
const activeIndex = ref(-1);
const searchItems = shallowRef<MenuRecordRaw[]>([]);
const searchResults = ref<MenuRecordRaw[]>([]);
const isNavigating = ref(false);
const handleSearch = useThrottleFn(search, 200);
@ -116,6 +122,7 @@ function handleUp() {
if (searchResults.value.length === 0) {
return;
}
isNavigating.value = true;
activeIndex.value--;
if (activeIndex.value < 0) {
activeIndex.value = searchResults.value.length - 1;
@ -128,6 +135,7 @@ function handleDown() {
if (searchResults.value.length === 0) {
return;
}
isNavigating.value = true;
activeIndex.value++;
if (activeIndex.value > searchResults.value.length - 1) {
activeIndex.value = 0;
@ -143,6 +151,7 @@ function handleClose() {
// Activate when the mouse moves to a certain line
function handleMouseenter(e: MouseEvent) {
if (isNavigating.value) return;
const index = (e.target as HTMLElement)?.dataset.index;
activeIndex.value = Number(index);
}
@ -221,6 +230,10 @@ onMounted(() => {
// esc close
onKeyStroke('Escape', handleClose);
});
useEventListener('mousemove', () => {
isNavigating.value = false;
});
</script>
<template>

4
packages/effects/layouts/src/widgets/preferences/blocks/layout/widget.vue

@ -23,6 +23,7 @@ const appPreferencesButtonPosition = defineModel<string>(
'appPreferencesButtonPosition',
);
const widgetRefresh = defineModel<boolean>('widgetRefresh');
const widgetTimezone = defineModel<boolean>('widgetTimezone');
const positionItems = computed((): SelectOption[] => [
{
@ -65,6 +66,9 @@ const positionItems = computed((): SelectOption[] => [
<SwitchItem v-model="widgetRefresh">
{{ $t('preferences.widget.refresh') }}
</SwitchItem>
<SwitchItem v-model="widgetTimezone">
{{ $t('preferences.widget.timezone') }}
</SwitchItem>
<SelectItem v-model="appPreferencesButtonPosition" :items="positionItems">
{{ $t('preferences.position.title') }}
</SelectItem>

6
packages/effects/layouts/src/widgets/preferences/blocks/theme/builtin.vue

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

8
packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue

@ -178,6 +178,7 @@ const widgetThemeToggle = defineModel<boolean>('widgetThemeToggle');
const widgetSidebarToggle = defineModel<boolean>('widgetSidebarToggle');
const widgetLockScreen = defineModel<boolean>('widgetLockScreen');
const widgetRefresh = defineModel<boolean>('widgetRefresh');
const widgetTimezone = defineModel<boolean>('widgetTimezone');
const {
customPreferences,
@ -282,8 +283,8 @@ async function handleCopy() {
}
async function handleClearCache() {
resetPreferences();
clearCache();
await resetPreferences();
await clearCache();
emit('clearPreferencesAndLogout');
}
@ -291,7 +292,7 @@ async function handleReset() {
if (!mergedDiffPreference.value) {
return;
}
resetPreferences();
await resetPreferences();
await loadLocaleMessages(preferences.app.locale);
}
@ -485,6 +486,7 @@ function handleCustomPreferencesUpdate(updates: CustomPreferencesRecord) {
v-model:widget-refresh="widgetRefresh"
v-model:widget-sidebar-toggle="widgetSidebarToggle"
v-model:widget-theme-toggle="widgetThemeToggle"
v-model:widget-timezone="widgetTimezone"
/>
</Block>
<Block :title="$t('preferences.footer.title')">

1
packages/effects/plugins/package.json

@ -45,6 +45,7 @@
"@tiptap/extension-text-align": "catalog:",
"@tiptap/extension-text-style": "catalog:",
"@tiptap/extension-underline": "catalog:",
"@tiptap/pm": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@tiptap/vue-3": "catalog:",
"@vben-core/design": "workspace:*",

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save