diff --git a/apps/vben5/.changeset/.prettierrc.json b/apps/vben5/.changeset/.prettierrc.json new file mode 100644 index 000000000..544138be4 --- /dev/null +++ b/apps/vben5/.changeset/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/apps/vben5/.changeset/fancy-ears-walk.md b/apps/vben5/.changeset/fancy-ears-walk.md new file mode 100644 index 000000000..f73cb596b --- /dev/null +++ b/apps/vben5/.changeset/fancy-ears-walk.md @@ -0,0 +1,7 @@ +--- +'@vben/styles': patch +'@vben-core/form-ui': patch +'@vben/web-naive': patch +--- + +feat(@core/form-ui): 新增 useVbenForm 数组编辑器 VbenFormFieldArray diff --git a/apps/vben5/.gitignore b/apps/vben5/.gitignore index 8cb16e7e0..12c3e492a 100644 --- a/apps/vben5/.gitignore +++ b/apps/vben5/.gitignore @@ -23,7 +23,8 @@ pnpm-lock.yaml package-lock.json .VSCodeCounter **/backend-mock/data - +.omx +.pnpm-store # local env files .env.local .env.*.local @@ -51,3 +52,12 @@ vite.config.ts.* *.sw? .history .cursor + +# AI +.agent +.agents +.claude +.codex +skills-lock.json +.atomcode +datalog diff --git a/apps/vben5/.node-version b/apps/vben5/.node-version index 85e502778..b832e4001 100644 --- a/apps/vben5/.node-version +++ b/apps/vben5/.node-version @@ -1 +1 @@ -22.22.0 +24.16.0 diff --git a/apps/vben5/.npmrc b/apps/vben5/.npmrc index aeac1ae91..7549542d7 100644 --- a/apps/vben5/.npmrc +++ b/apps/vben5/.npmrc @@ -1,13 +1 @@ registry=https://registry.npmmirror.com -public-hoist-pattern[]=lefthook -public-hoist-pattern[]=eslint -public-hoist-pattern[]=prettier -public-hoist-pattern[]=prettier-plugin-tailwindcss -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 diff --git a/apps/vben5/.prettierignore b/apps/vben5/.prettierignore deleted file mode 100644 index 7c572fd66..000000000 --- a/apps/vben5/.prettierignore +++ /dev/null @@ -1,20 +0,0 @@ -dist -dev-dist -.local -.output.js -node_modules -.nvmrc -coverage -CODEOWNERS -.nitro -.output - - -**/*.svg -**/*.sh - -public -.npmrc -*-lock.yaml - -packages/@abp/components diff --git a/apps/vben5/.prettierrc.mjs b/apps/vben5/.prettierrc.mjs deleted file mode 100644 index 3e25d2cfa..000000000 --- a/apps/vben5/.prettierrc.mjs +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@vben/prettier-config'; diff --git a/apps/vben5/.stylelintignore b/apps/vben5/.stylelintignore index 339bcf6c2..3adb33b22 100644 --- a/apps/vben5/.stylelintignore +++ b/apps/vben5/.stylelintignore @@ -2,4 +2,7 @@ dist public __tests__ coverage -packages/@abp/components +.codex +.claude +.agent +.agents diff --git a/apps/vben5/.vscode/extensions.json b/apps/vben5/.vscode/extensions.json index e8dc9ed9b..d4e28662b 100644 --- a/apps/vben5/.vscode/extensions.json +++ b/apps/vben5/.vscode/extensions.json @@ -2,14 +2,18 @@ "recommendations": [ // Vue 3 的语言支持 "Vue.volar", - // 将 ESLint JavaScript 集成到 VS Code 中。 + // 将 eslint 集成到 VS Code 中。 "dbaeumer.vscode-eslint", + // 将 oxlint 集成到 VS Code 中。 + "oxc.oxc-vscode", // Visual Studio Code 的官方 Stylelint 扩展 "stylelint.vscode-stylelint", - // 使用 Prettier 的代码格式化程序 - "esbenp.prettier-vscode", + // 使用 oxfmt 的代码格式化程序 + "oxc.oxc-vscode", // 支持 dotenv 文件语法 "mikestead.dotenv", + // YAML 语言支持,供 ESLint 校验 pnpm-workspace.yaml 等文件 + "redhat.vscode-yaml", // 源代码的拼写检查器 "streetsidesoftware.code-spell-checker", // Tailwind CSS 的官方 VS Code 插件 diff --git a/apps/vben5/.vscode/settings.json b/apps/vben5/.vscode/settings.json index 8da37dc96..993b52039 100644 --- a/apps/vben5/.vscode/settings.json +++ b/apps/vben5/.vscode/settings.json @@ -1,5 +1,6 @@ { - "tailwindCSS.experimental.configFile": "internal/tailwind-config/src/index.ts", + "tailwindCSS.experimental.configFile": "internal/tailwind-config/src/theme.css", + "tailwindCSS.lint.suggestCanonicalClasses": "ignore", // workbench "workbench.list.smoothScrolling": true, "workbench.startupEditor": "newUntitledFile", @@ -31,39 +32,51 @@ "editor.autoClosingOvertype": "always", "editor.autoClosingQuotes": "beforeWhitespace", "editor.wordSeparators": "`~!@#%^&*()=+[{]}\\|;:'\",.<>/?", + "editor.quickSuggestions": { + "strings": "on" + }, + + // lint && format + "oxc.enable": true, + "oxc.typeAware": true, + "oxc.configPath": "oxlint.config.ts", + "oxc.fmt.configPath": "oxfmt.config.ts", + "eslint.useFlatConfig": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", + "source.fixAll.oxc": "explicit", "source.fixAll.stylelint": "explicit", "source.organizeImports": "never" }, - "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.defaultFormatter": "oxc.oxc-vscode", "[html]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[css]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[scss]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[javascript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[markdown]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[jsonc]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "oxc.oxc-vscode" }, "[vue]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "oxc.oxc-vscode" }, + // extensions "extensions.ignoreRecommendations": true, @@ -79,6 +92,7 @@ "files.insertFinalNewline": true, "files.simpleDialog.enable": true, "files.associations": { + "*.css": "tailwindcss", "*.ejs": "html", "*.art": "html", "**/tsconfig.json": "jsonc", @@ -118,7 +132,7 @@ // search "search.searchEditor.singleClickBehaviour": "peekDefinition", "search.followSymlinks": false, - // 在使用搜索功能时,将这些文件夹/文件排除在外 + // 使用搜索功能时,将这些文件和文件夹排除在外 "search.exclude": { "**/node_modules": true, "**/*.log": true, @@ -158,7 +172,7 @@ "emmet.triggerExpansionOnTab": false, "errorLens.enabledDiagnosticLevels": ["warning", "error"], - "errorLens.excludeBySource": ["cSpell", "Grammarly", "eslint"], + "errorLens.excludeBySource": ["cSpell", "Grammarly"], "stylelint.enable": true, "stylelint.packageManager": "pnpm", @@ -166,9 +180,10 @@ "stylelint.customSyntax": "postcss-html", "stylelint.snippet": ["css", "less", "postcss", "scss", "vue"], - "typescript.inlayHints.enumMemberValues.enabled": true, - "typescript.preferences.preferTypeOnlyAutoImports": true, - "typescript.preferences.includePackageJsonAutoImports": "on", + "js/ts.tsdk.path": "node_modules/typescript/lib", + "js/ts.inlayHints.enumMemberValues.enabled": true, + "js/ts.preferences.preferTypeOnlyAutoImports": true, + "js/ts.preferences.includePackageJsonAutoImports": "on", "eslint.validate": [ "javascript", @@ -195,7 +210,7 @@ "yaml": false }, - "cssVariables.lookupFiles": ["packages/core/base/design/src/**/*.css"], + "cssVariables.lookupFiles": ["packages/@core/base/design/src/**/*.css"], "i18n-ally.localesPaths": [ "packages/locales/src/langs", @@ -220,12 +235,9 @@ "*.env": "$(capture).env.*", "README.md": "README*,CHANGELOG*,LICENSE,CNAME", "package.json": "pnpm-lock.yaml,pnpm-workspace.yaml,.gitattributes,.gitignore,.gitpod.yml,.npmrc,.browserslistrc,.node-version,.git*,.tazerc.json", - "eslint.config.mjs": ".eslintignore,.prettierignore,.stylelintignore,.commitlintrc.*,.prettierrc.*,stylelint.config.*,.lintstagedrc.mjs,cspell.json,lefthook.yml", - "tailwind.config.mjs": "postcss.*" + "oxlint.config.ts": ".eslintignore,.stylelintignore,.commitlintrc.*,stylelint.config.*,.lintstagedrc.mjs,cspell.json,lefthook.yml,oxfmt.config.*,eslint.config.*" }, "commentTranslate.hover.enabled": false, "commentTranslate.multiLineMerge": true, - "vue.server.hybridMode": true, - "typescript.tsdk": "node_modules/typescript/lib", - "oxc.enable": false + "vue.server.hybridMode": true } diff --git a/apps/vben5/README.ja-JP.md b/apps/vben5/README.ja-JP.md index 4ce285a74..ca0a9775d 100644 --- a/apps/vben5/README.ja-JP.md +++ b/apps/vben5/README.ja-JP.md @@ -114,7 +114,7 @@ pnpm build ## ブラウザサポート -ローカル開発には `Chrome 80+` ブラウザを推奨します +Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+ モダンブラウザをサポートし、IEはサポートしません diff --git a/apps/vben5/README.md b/apps/vben5/README.md index ce8e89758..e1c9f29cd 100644 --- a/apps/vben5/README.md +++ b/apps/vben5/README.md @@ -114,7 +114,7 @@ Reference [vue](https://github.com/vuejs/vue/blob/dev/.github/COMMIT_CONVENTION. ## Browser Support -The `Chrome 80+` browser is recommended for local development +Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+ Support modern browsers, not IE diff --git a/apps/vben5/README.zh-CN.md b/apps/vben5/README.zh-CN.md index d3193ef65..da7ec5fa8 100644 --- a/apps/vben5/README.zh-CN.md +++ b/apps/vben5/README.zh-CN.md @@ -114,7 +114,7 @@ pnpm build ## 浏览器支持 -本地开发推荐使用 `Chrome 80+` 浏览器 +Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+ 支持现代浏览器,不支持 IE diff --git a/apps/vben5/apps/app-antd/.env.development b/apps/vben5/apps/app-antd/.env.development index ef5b909eb..a669efbe0 100644 --- a/apps/vben5/apps/app-antd/.env.development +++ b/apps/vben5/apps/app-antd/.env.development @@ -17,8 +17,9 @@ VITE_INJECT_APP_LOADING=true # 是否仅允许OIDC登录 VITE_GLOB_AUTH_ONLY_OIDC=true +VITE_GLOB_AUTH_DISABLE_PKCE=true # 认证服务器 -VITE_GLOB_AUTH_AUTHORITY="http://localhost:30000" +VITE_GLOB_AUTH_AUTHORITY="http://99.22.20.9:30000" # 授权范围 VITE_GLOB_AUTH_AUDIENCE="openid email address phone profile offline_access lingyun-abp-application" # 客户端Id diff --git a/apps/vben5/apps/app-antd/package.json b/apps/vben5/apps/app-antd/package.json index 8fe700c52..4ae7bbc58 100644 --- a/apps/vben5/apps/app-antd/package.json +++ b/apps/vben5/apps/app-antd/package.json @@ -50,6 +50,7 @@ "@abp/ui": "workspace:*", "@abp/webhooks": "workspace:*", "@abp/wechat": "workspace:*", + "@tanstack/vue-query": "catalog:", "@vben-core/shadcn-ui": "workspace:*", "@vben/access": "workspace:*", "@vben/common-ui": "workspace:*", diff --git a/apps/vben5/apps/app-antd/postcss.config.mjs b/apps/vben5/apps/app-antd/postcss.config.mjs deleted file mode 100644 index 3d8070455..000000000 --- a/apps/vben5/apps/app-antd/postcss.config.mjs +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@vben/tailwind-config/postcss'; diff --git a/apps/vben5/apps/app-antd/src/adapter/component/index.ts b/apps/vben5/apps/app-antd/src/adapter/component/index.ts index 4ac7e46f3..902b77237 100644 --- a/apps/vben5/apps/app-antd/src/adapter/component/index.ts +++ b/apps/vben5/apps/app-antd/src/adapter/component/index.ts @@ -6,14 +6,39 @@ /* eslint-disable vue/one-component-per-file */ import type { + AutoCompleteProps, + ButtonProps, + CascaderProps, + CheckboxGroupProps, + CheckboxProps, + DatePickerProps, + DividerProps, + InputNumberProps, + InputProps, + MentionsProps, + RadioGroupProps, + RadioProps, + RateProps, + SelectProps, + SpaceProps, + SwitchProps, + TextAreaProps, + TimePickerProps, + TreeSelectProps, UploadChangeParam, UploadFile, UploadProps, } from 'ant-design-vue'; +import type { RangePickerProps } from 'ant-design-vue/es/date-picker'; import type { Component, Ref } from 'vue'; -import type { BaseFormComponentType } from '@vben/common-ui'; +import type { + ApiComponentSharedProps, + BaseFormComponentType, + IconPickerProps, +} from '@vben/common-ui'; +import type { Sortable } from '@vben/hooks'; import type { Recordable } from '@vben/types'; import { @@ -21,6 +46,9 @@ import { defineAsyncComponent, defineComponent, h, + nextTick, + onMounted, + onUnmounted, ref, render, unref, @@ -33,6 +61,7 @@ import { IconPicker, VCropper, } from '@vben/common-ui'; +import { useSortable } from '@vben/hooks'; import { IconifyIcon } from '@vben/icons'; import { $t } from '@vben/locales'; import { isEmpty } from '@vben/utils'; @@ -40,7 +69,7 @@ import { isEmpty } from '@vben/utils'; import { FeatureStateCheck, GlobalFeatureStateCheck } from '@abp/features'; import { PermissionStateCheck } from '@abp/permissions'; import { TenantSelect } from '@abp/saas'; -import { message, Modal, notification } from 'ant-design-vue'; +import { Empty, InputSearch, message, Modal, notification, Tree } from 'ant-design-vue'; const ColorPicker = defineAsyncComponent(() => import('vue3-colorpicker').then((res) => { @@ -49,6 +78,16 @@ const ColorPicker = defineAsyncComponent(() => }), ); +type AdapterUploadProps = UploadProps & { + aspectRatio?: string; + crop?: boolean; + draggable?: boolean; + handleChange?: (event: UploadChangeParam) => void; + maxSize?: number; + onDragSort?: (oldIndex: number, newIndex: number) => void; + onHandleChange?: (event: UploadChangeParam) => void; +}; + const AutoComplete = defineAsyncComponent( () => import('ant-design-vue/es/auto-complete'), ); @@ -63,7 +102,6 @@ const DatePicker = defineAsyncComponent( () => import('ant-design-vue/es/date-picker'), ); const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider')); -const Empty = defineAsyncComponent(() => import('ant-design-vue/es/empty')); const Input = defineAsyncComponent(() => import('ant-design-vue/es/input')); const InputNumber = defineAsyncComponent( () => import('ant-design-vue/es/input-number'), @@ -71,9 +109,6 @@ const InputNumber = defineAsyncComponent( const InputPassword = defineAsyncComponent(() => import('ant-design-vue/es/input').then((res) => res.InputPassword), ); -const InputSearch = defineAsyncComponent(() => - import('ant-design-vue/es/input').then((res) => res.InputSearch), -); const Mentions = defineAsyncComponent( () => import('ant-design-vue/es/mentions'), ); @@ -94,7 +129,6 @@ const Textarea = defineAsyncComponent(() => const TimePicker = defineAsyncComponent( () => import('ant-design-vue/es/time-picker'), ); -const Tree = defineAsyncComponent(() => import('ant-design-vue/es/tree')); const TreeSelect = defineAsyncComponent( () => import('ant-design-vue/es/tree-select'), ); @@ -107,8 +141,8 @@ const PreviewGroup = defineAsyncComponent(() => import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup), ); -const withDefaultPlaceholder = ( - component: T, +const withDefaultPlaceholder = ( + component: Component, type: 'input' | 'select', componentProps: Recordable = {}, ) => { @@ -141,260 +175,261 @@ const withDefaultPlaceholder = ( }); }; -const withPreviewUpload = () => { - // 检查是否为图片文件的辅助函数 - const isImageFile = (file: UploadFile): boolean => { - const imageExtensions = new Set([ - 'bmp', - 'gif', - 'jpeg', - 'jpg', - 'png', - 'svg', - 'webp', - ]); - if (file.url) { - try { - const pathname = new URL(file.url, 'http://localhost').pathname; - const ext = pathname.split('.').pop()?.toLowerCase(); - return ext ? imageExtensions.has(ext) : false; - } catch { - const ext = file.url?.split('.').pop()?.toLowerCase(); - return ext ? imageExtensions.has(ext) : false; - } - } - if (!file.type) { - const ext = file.name?.split('.').pop()?.toLowerCase(); - return ext ? imageExtensions.has(ext) : false; +const IMAGE_EXTENSIONS = new Set([ + 'bmp', + 'gif', + 'jpeg', + 'jpg', + 'png', + 'svg', + 'webp', +]); + +/** + * 检查是否为图片文件 + */ +function isImageFile(file: UploadFile): boolean { + if (file.url) { + try { + const pathname = new URL(file.url, 'http://localhost').pathname; + const ext = pathname.split('.').pop()?.toLowerCase(); + return ext ? IMAGE_EXTENSIONS.has(ext) : false; + } catch { + const ext = file.url?.split('.').pop()?.toLowerCase(); + return ext ? IMAGE_EXTENSIONS.has(ext) : false; } - return file.type.startsWith('image/'); + } + if (!file.type) { + const ext = file.name?.split('.').pop()?.toLowerCase(); + return ext ? IMAGE_EXTENSIONS.has(ext) : false; + } + return file.type.startsWith('image/'); +} + +/** + * 创建默认的上传按钮插槽 + */ +function createDefaultUploadSlots(listType: string, placeholder: string) { + if (listType === 'picture-card') { + return { default: () => placeholder }; + } + return { + default: () => + h( + Button, + { + icon: h(IconifyIcon, { + icon: 'ant-design:upload-outlined', + class: 'mb-1 size-4', + }), + }, + () => placeholder, + ), }; - // 创建默认的上传按钮插槽 - const createDefaultSlotsWithUpload = ( - listType: string, - placeholder: string, - ) => { - switch (listType) { - case 'picture-card': { - return { - default: () => placeholder, - }; - } - default: { - return { - default: () => - h( - Button, - { - icon: h(IconifyIcon, { - icon: 'ant-design:upload-outlined', - class: 'mb-1 size-4', - }), +} + +/** + * 获取文件的 Base64 + */ +function getBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.addEventListener('load', () => resolve(reader.result as string)); + reader.addEventListener('error', reject); + }); +} + +/** + * 预览图片 + */ +async function previewImage( + file: UploadFile, + visible: Ref, + fileList: Ref, +) { + // 非图片文件直接打开链接 + if (!isImageFile(file)) { + const url = file.url || file.preview; + if (url) { + window.open(url, '_blank'); + } else { + message.error($t('ui.formRules.previewWarning')); + } + return; + } + + const [ImageComponent, PreviewGroupComponent] = await Promise.all([ + Image, + PreviewGroup, + ]); + + // 过滤图片文件并生成预览 + const imageFiles = (unref(fileList) || []).filter((f) => isImageFile(f)); + + for (const imgFile of imageFiles) { + if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) { + imgFile.preview = await getBase64(imgFile.originFileObj); + } + } + + const container = document.createElement('div'); + document.body.append(container); + let isUnmounted = false; + + const currentIndex = imageFiles.findIndex((f) => f.uid === file.uid); + + const PreviewWrapper = { + setup() { + return () => { + if (isUnmounted) return null; + return h( + PreviewGroupComponent, + { + class: 'hidden', + preview: { + visible: visible.value, + current: currentIndex, + onVisibleChange: (value: boolean) => { + visible.value = value; + if (!value) { + setTimeout(() => { + if (!isUnmounted && container) { + isUnmounted = true; + render(null, container); + container.remove(); + } + }, 300); + } }, - () => placeholder, + }, + }, + () => + imageFiles.map((imgFile) => + h(ImageComponent, { + key: imgFile.uid, + src: imgFile.url || imgFile.preview, + }), ), - }; - } - } + ); + }; + }, }; - // 构建预览图片组 - const previewImage = async ( - file: UploadFile, - visible: Ref, - fileList: Ref, - ) => { - // 如果当前文件不是图片,直接打开 - if (!isImageFile(file)) { - if (file.url) { - window.open(file.url, '_blank'); - } else if (file.preview) { - window.open(file.preview, '_blank'); - } else { - message.error($t('ui.formRules.previewWarning')); - } - return; - } - // 对于图片文件,继续使用预览组 - const [ImageComponent, PreviewGroupComponent] = await Promise.all([ - Image, - PreviewGroup, - ]); - - const getBase64 = (file: File) => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.addEventListener('load', () => resolve(reader.result)); - reader.addEventListener('error', (error) => reject(error)); - }); - }; - // 从fileList中过滤出所有图片文件 - const imageFiles = (unref(fileList) || []).filter((element) => - isImageFile(element), - ); - - // 为所有没有预览地址的图片生成预览 - for (const imgFile of imageFiles) { - if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) { - imgFile.preview = (await getBase64(imgFile.originFileObj)) as string; - } - } - const container: HTMLElement | null = document.createElement('div'); + render(h(PreviewWrapper), container); +} + +/** + * 图片裁剪操作 + */ +function cropImage(file: File, aspectRatio: string | undefined) { + return new Promise((resolve, reject) => { + const container = document.createElement('div'); document.body.append(container); - // 用于追踪组件是否已卸载 let isUnmounted = false; + let objectUrl: null | string = null; - const PreviewWrapper = { + const open = ref(true); + const cropperRef = ref | null>(null); + + const closeModal = () => { + open.value = false; + setTimeout(() => { + if (!isUnmounted && container) { + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + isUnmounted = true; + render(null, container); + container.remove(); + } + }, 300); + }; + + const CropperWrapper = { setup() { return () => { if (isUnmounted) return null; + if (!objectUrl) { + objectUrl = URL.createObjectURL(file); + } return h( - PreviewGroupComponent, + Modal, { - class: 'hidden', - preview: { - visible: visible.value, - // 设置初始显示的图片索引 - current: imageFiles.findIndex((f) => f.uid === file.uid), - onVisibleChange: (value: boolean) => { - visible.value = value; - if (!value) { - // 延迟清理,确保动画完成 - setTimeout(() => { - if (!isUnmounted && container) { - isUnmounted = true; - render(null, container); - container.remove(); - } - }, 300); + open: open.value, + title: h('div', {}, [ + $t('ui.crop.title'), + h( + 'span', + { + class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`, + }, + $t('ui.crop.titleTip', [aspectRatio]), + ), + ]), + centered: true, + width: 548, + keyboard: false, + maskClosable: false, + closable: false, + cancelText: $t('common.cancel'), + okText: $t('ui.crop.confirm'), + destroyOnClose: true, + onOk: async () => { + const cropper = cropperRef.value; + if (!cropper) { + reject(new Error('Cropper not found')); + closeModal(); + return; + } + try { + const dataUrl = await cropper.getCropImage(); + if (dataUrl) { + resolve(dataUrl); + } else { + reject(new Error($t('ui.crop.errorTip'))); } - }, + } catch { + reject(new Error($t('ui.crop.errorTip'))); + } finally { + closeModal(); + } + }, + onCancel() { + resolve(''); + closeModal(); }, }, () => - // 渲染所有图片文件 - imageFiles.map((imgFile) => - h(ImageComponent, { - key: imgFile.uid, - src: imgFile.url || imgFile.preview, - }), - ), + h(VCropper, { + ref: (ref: any) => (cropperRef.value = ref), + img: objectUrl as string, + aspectRatio, + }), ); }; }, }; - render(h(PreviewWrapper), container); - }; - - // 图片裁剪操作 - const cropImage = (file: File, aspectRatio: string | undefined) => { - return new Promise((resolve, reject) => { - const container: HTMLElement | null = document.createElement('div'); - document.body.append(container); - - // 用于追踪组件是否已卸载 - let isUnmounted = false; - let objectUrl: null | string = null; - - const open = ref(true); - const cropperRef = ref | null>(null); - - const closeModal = () => { - open.value = false; - // 延迟清理,确保动画完成 - setTimeout(() => { - if (!isUnmounted && container) { - if (objectUrl) { - URL.revokeObjectURL(objectUrl); - } - isUnmounted = true; - render(null, container); - container.remove(); - } - }, 300); - }; - - const CropperWrapper = { - setup() { - return () => { - if (isUnmounted) return null; - if (!objectUrl) { - objectUrl = URL.createObjectURL(file); - } - return h( - Modal, - { - open: open.value, - title: h('div', {}, [ - $t('ui.crop.title'), - h( - 'span', - { - class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`, - }, - $t('ui.crop.titleTip', [aspectRatio]), - ), - ]), - centered: true, - width: 548, - keyboard: false, - maskClosable: false, - closable: false, - cancelText: $t('common.cancel'), - okText: $t('ui.crop.confirm'), - destroyOnClose: true, - onOk: async () => { - const cropper = cropperRef.value; - if (!cropper) { - reject(new Error('Cropper not found')); - closeModal(); - return; - } - try { - const dataUrl = await cropper.getCropImage(); - resolve(dataUrl); - } catch { - reject(new Error($t('ui.crop.errorTip'))); - } finally { - closeModal(); - } - }, - onCancel() { - resolve(''); - closeModal(); - }, - }, - () => - h(VCropper, { - ref: (ref: any) => (cropperRef.value = ref), - img: objectUrl as string, - aspectRatio, - }), - ); - }; - }, - }; - - render(h(CropperWrapper), container); - }); - }; + render(h(CropperWrapper), container); + }); +} +/** + * 带预览功能的上传组件 + */ +const withPreviewUpload = () => { return defineComponent({ name: Upload.name, emits: ['update:modelValue'], - setup: ( + setup( props: any, { attrs, slots, emit }: { attrs: any; emit: any; slots: any }, - ) => { + ) { const previewVisible = ref(false); - - const placeholder = attrs?.placeholder || $t(`ui.placeholder.upload`); - + const placeholder = attrs?.placeholder || $t('ui.placeholder.upload'); const listType = attrs?.listType || attrs?.['list-type'] || 'text'; - const fileList = ref( attrs?.fileList || attrs?.['file-list'] || [], ); @@ -408,12 +443,14 @@ const withPreviewUpload = () => { file: UploadFile, originFileList: Array, ) => { + // 文件大小限制 if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) { message.error($t('ui.formRules.sizeLimit', [maxSize.value])); file.status = 'removed'; return false; } - // 多选或者非图片不唤起裁剪框 + + // 图片裁剪处理 if ( attrs.crop && !attrs.multiple && @@ -421,14 +458,11 @@ const withPreviewUpload = () => { isImageFile(file) ) { file.status = 'removed'; - // antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取 const blob = await cropImage(originFileList[0], aspectRatio.value); - return new Promise((resolve, reject) => { - if (!blob) { - return reject(new Error($t('ui.crop.errorTip'))); - } - resolve(blob); - }); + if (!blob) { + throw new Error($t('ui.crop.errorTip')); + } + return blob; } return attrs.beforeUpload?.(file) ?? true; @@ -436,12 +470,9 @@ const withPreviewUpload = () => { const handleChange = (event: UploadChangeParam) => { try { - // 行内写法 handleChange: (event) => {} attrs.handleChange?.(event); - // template写法 @handle-change="(event) => {}" attrs.onHandleChange?.(event); } catch (error) { - // Avoid breaking internal v-model sync on user handler errors console.error(error); } fileList.value = event.fileList.filter( @@ -458,21 +489,88 @@ const withPreviewUpload = () => { await previewImage(file, previewVisible, fileList); }; - const renderUploadButton = (): any => { - const isDisabled = attrs.disabled; - - // 如果禁用,不渲染上传按钮 - if (isDisabled) { - return null; - } - - // 否则渲染默认上传按钮 + const renderUploadButton = () => { + if (attrs.disabled) return null; return isEmpty(slots) - ? createDefaultSlotsWithUpload(listType, placeholder) + ? createDefaultUploadSlots(listType, placeholder) : slots; }; - // 可以监听到表单API设置的值 + // 拖拽排序 + const draggable = computed( + () => (attrs.draggable ?? false) && !attrs.disabled, + ); + const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const sortableInstance = ref(null); + + const styleId = `upload-drag-style-${uploadId}`; + + function injectDragStyle() { + if (!document.querySelector(`[id="${styleId}"]`)) { + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + [data-upload-id="${uploadId}"] .ant-upload-list-item { cursor: move; } + [data-upload-id="${uploadId}"] .ant-upload-list-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15); } + `; + document.head.append(style); + } + } + + function removeDragStyle() { + document.querySelector(`[id="${styleId}"]`)?.remove(); + } + + async function initSortable(retryCount = 0) { + if (!draggable.value) return; + + injectDragStyle(); + await nextTick(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const container = document.querySelector( + `[data-upload-id="${uploadId}"] .ant-upload-list`, + ) as HTMLElement; + + if (!container) { + if (retryCount < 5) { + setTimeout(() => initSortable(retryCount + 1), 200); + } + return; + } + + const { initializeSortable } = useSortable(container, { + animation: 300, + delay: 400, + delayOnTouchOnly: true, + filter: + '.ant-upload-select, .ant-upload-list-item-error, .ant-upload-list-item-uploading', + onEnd: (evt) => { + const { oldIndex, newIndex } = evt; + if ( + oldIndex === undefined || + newIndex === undefined || + oldIndex === newIndex + ) { + return; + } + + const list = [...(fileList.value || [])]; + const [movedItem] = list.splice(oldIndex, 1); + if (movedItem) { + list.splice(newIndex, 0, movedItem); + fileList.value = list; + } + + attrs.onDragSort?.(oldIndex, newIndex); + emit('update:modelValue', fileList.value); + }, + }); + + sortableInstance.value = await initializeSortable(); + } + + // 监听表单值变化 watch( () => attrs.modelValue, (res) => { @@ -480,18 +578,28 @@ const withPreviewUpload = () => { }, ); + onMounted(initSortable); + onUnmounted(() => { + sortableInstance.value?.destroy(); + removeDragStyle(); + }); + return () => h( - Upload, - { - ...props, - ...attrs, - fileList: fileList.value, - beforeUpload: handleBeforeUpload, - onChange: handleChange, - onPreview: handlePreview, - }, - renderUploadButton(), + 'div', + { 'data-upload-id': uploadId, class: 'w-full' }, + h( + Upload, + { + ...props, + ...attrs, + fileList: fileList.value, + beforeUpload: handleBeforeUpload, + onChange: handleChange, + onPreview: handlePreview, + }, + renderUploadButton() as any, + ), ); }, }); @@ -536,6 +644,39 @@ export type ComponentType = | 'Upload' | BaseFormComponentType; +/** + * 与 {@link ComponentType} 中注册的组件名一一对应,便于 Schema 上 `component` + `componentProps` 联动提示 + */ +export interface ComponentPropsMap { + ApiCascader: ApiComponentSharedProps & CascaderProps; + ApiSelect: ApiComponentSharedProps & SelectProps; + ApiTreeSelect: ApiComponentSharedProps & TreeSelectProps; + AutoComplete: AutoCompleteProps; + Cascader: CascaderProps; + Checkbox: CheckboxProps; + CheckboxGroup: CheckboxGroupProps; + DatePicker: DatePickerProps; + DefaultButton: ButtonProps; + Divider: DividerProps; + IconPicker: IconPickerProps; + Input: InputProps; + InputNumber: InputNumberProps; + InputPassword: InputProps; + Mentions: MentionsProps; + PrimaryButton: ButtonProps; + Radio: RadioProps; + RadioGroup: RadioGroupProps; + RangePicker: RangePickerProps; + Rate: RateProps; + Select: SelectProps; + Space: SpaceProps; + Switch: SwitchProps; + Textarea: TextAreaProps; + TimePicker: TimePickerProps; + TreeSelect: TreeSelectProps; + Upload: AdapterUploadProps; +} + async function initComponentAdapter() { const components: Partial> = { // 如果你的组件体积比较大,可以使用异步加载 diff --git a/apps/vben5/apps/app-antd/src/bootstrap.ts b/apps/vben5/apps/app-antd/src/bootstrap.ts index f75585871..5dc0bbd3d 100644 --- a/apps/vben5/apps/app-antd/src/bootstrap.ts +++ b/apps/vben5/apps/app-antd/src/bootstrap.ts @@ -2,6 +2,7 @@ import { createApp, watchEffect } from 'vue'; import { registerAccessDirective } from '@vben/access'; import { registerLoadingDirective } from '@vben/common-ui'; +import { providePluginsOptions } from '@vben/plugins'; import { preferences } from '@vben/preferences'; import { initStores } from '@vben/stores'; import '@vben/styles'; @@ -12,7 +13,7 @@ import { useTitle } from '@vueuse/core'; import { $t, setupI18n } from '#/locales'; import { initComponentAdapter } from './adapter/component'; -import { initSetupVbenForm } from './adapter/form'; +import { initSetupVbenForm, useVbenForm } from './adapter/form'; import { initRequestClient } from './adapter/request'; import App from './app.vue'; import { router } from './router'; @@ -25,6 +26,11 @@ async function bootstrap(namespace: string) { // 初始化表单组件 await initSetupVbenForm(); + // 注入插件全局配置 + providePluginsOptions({ + form: { useVbenForm }, + }); + // 初始化axios initRequestClient(); @@ -64,6 +70,10 @@ async function bootstrap(namespace: string) { // 配置路由及路由守卫 app.use(router); + // 配置@tanstack/vue-query + const { VueQueryPlugin } = await import('@tanstack/vue-query'); + app.use(VueQueryPlugin); + // 配置Motion插件 const { MotionPlugin } = await import('@vben/plugins/motion'); app.use(MotionPlugin); diff --git a/apps/vben5/apps/app-antd/src/preferences.ts b/apps/vben5/apps/app-antd/src/preferences.ts index 1591bd1d8..54d2511cf 100644 --- a/apps/vben5/apps/app-antd/src/preferences.ts +++ b/apps/vben5/apps/app-antd/src/preferences.ts @@ -1,4 +1,4 @@ -import { defineOverridesPreferences } from '@vben/preferences'; +import { appCopyrightPreferences, defineOverridesPreferences } from '@vben/preferences'; /** * @description 项目配置文件 @@ -13,6 +13,7 @@ export const overridesPreferences = defineOverridesPreferences({ enableRefreshToken: true, name: import.meta.env.VITE_APP_TITLE, }, + copyright: appCopyrightPreferences, logo: { source: '/resource/img/logo.png', }, diff --git a/apps/vben5/apps/app-antd/tailwind.config.mjs b/apps/vben5/apps/app-antd/tailwind.config.mjs deleted file mode 100644 index f17f556fa..000000000 --- a/apps/vben5/apps/app-antd/tailwind.config.mjs +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@vben/tailwind-config'; diff --git a/apps/vben5/apps/app-antd/tsconfig.json b/apps/vben5/apps/app-antd/tsconfig.json index 02c287fe6..858a0ec08 100644 --- a/apps/vben5/apps/app-antd/tsconfig.json +++ b/apps/vben5/apps/app-antd/tsconfig.json @@ -2,7 +2,6 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@vben/tsconfig/web-app.json", "compilerOptions": { - "baseUrl": ".", "paths": { "#/*": ["./src/*"] } diff --git a/apps/vben5/apps/app-antd/vite.config.mts b/apps/vben5/apps/app-antd/vite.config.mts index 8a822964f..29f4b2fc5 100644 --- a/apps/vben5/apps/app-antd/vite.config.mts +++ b/apps/vben5/apps/app-antd/vite.config.mts @@ -8,19 +8,19 @@ export default defineConfig(async () => { proxy: { '/.well-known': { changeOrigin: true, - target: 'http://localhost:30000/', + target: 'http://99.22.20.9:30000/', }, '/api': { changeOrigin: true, - target: 'http://localhost:30000/', + target: 'http://99.22.20.9:30000/', }, '/connect': { changeOrigin: true, - target: 'http://localhost:30000/', + target: 'http://99.22.20.9:30000/', }, '/signalr-hubs': { changeOrigin: true, - target: 'http://localhost:30000/', + target: 'http://99.22.20.9:30000/', ws: true, }, }, diff --git a/apps/vben5/apps/backend-mock/api/auth/codes.ts b/apps/vben5/apps/backend-mock/api/auth/codes.ts index 7ba012705..e610b3381 100644 --- a/apps/vben5/apps/backend-mock/api/auth/codes.ts +++ b/apps/vben5/apps/backend-mock/api/auth/codes.ts @@ -1,5 +1,7 @@ +import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; -import { unAuthorizedResponse } from '~/utils/response'; +import { MOCK_CODES } from '~/utils/mock-data'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; export default eventHandler((event) => { const userinfo = verifyAccessToken(event); diff --git a/apps/vben5/apps/backend-mock/api/auth/login.post.ts b/apps/vben5/apps/backend-mock/api/auth/login.post.ts index df5737a25..e23942c46 100644 --- a/apps/vben5/apps/backend-mock/api/auth/login.post.ts +++ b/apps/vben5/apps/backend-mock/api/auth/login.post.ts @@ -1,9 +1,15 @@ +import { defineEventHandler, readBody, setResponseStatus } from 'h3'; import { clearRefreshTokenCookie, setRefreshTokenCookie, } from '~/utils/cookie-utils'; import { generateAccessToken, generateRefreshToken } from '~/utils/jwt-utils'; -import { forbiddenResponse } from '~/utils/response'; +import { MOCK_USERS } from '~/utils/mock-data'; +import { + forbiddenResponse, + useResponseError, + useResponseSuccess, +} from '~/utils/response'; export default defineEventHandler(async (event) => { const { password, username } = await readBody(event); diff --git a/apps/vben5/apps/backend-mock/api/auth/logout.post.ts b/apps/vben5/apps/backend-mock/api/auth/logout.post.ts index ac6afe940..74c8d3151 100644 --- a/apps/vben5/apps/backend-mock/api/auth/logout.post.ts +++ b/apps/vben5/apps/backend-mock/api/auth/logout.post.ts @@ -1,7 +1,9 @@ +import { defineEventHandler } from 'h3'; import { clearRefreshTokenCookie, getRefreshTokenFromCookie, } from '~/utils/cookie-utils'; +import { useResponseSuccess } from '~/utils/response'; export default defineEventHandler(async (event) => { const refreshToken = getRefreshTokenFromCookie(event); diff --git a/apps/vben5/apps/backend-mock/api/auth/refresh.post.ts b/apps/vben5/apps/backend-mock/api/auth/refresh.post.ts index 7df4d34f5..7d8d3a51e 100644 --- a/apps/vben5/apps/backend-mock/api/auth/refresh.post.ts +++ b/apps/vben5/apps/backend-mock/api/auth/refresh.post.ts @@ -1,9 +1,11 @@ +import { defineEventHandler } from 'h3'; import { clearRefreshTokenCookie, getRefreshTokenFromCookie, setRefreshTokenCookie, } from '~/utils/cookie-utils'; -import { verifyRefreshToken } from '~/utils/jwt-utils'; +import { generateAccessToken, verifyRefreshToken } from '~/utils/jwt-utils'; +import { MOCK_USERS } from '~/utils/mock-data'; import { forbiddenResponse } from '~/utils/response'; export default defineEventHandler(async (event) => { diff --git a/apps/vben5/apps/backend-mock/api/demo/bigint.ts b/apps/vben5/apps/backend-mock/api/demo/bigint.ts index 880cc5ea8..00d6c28c5 100644 --- a/apps/vben5/apps/backend-mock/api/demo/bigint.ts +++ b/apps/vben5/apps/backend-mock/api/demo/bigint.ts @@ -1,3 +1,7 @@ +import { eventHandler, setHeader } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { unAuthorizedResponse } from '~/utils/response'; + export default eventHandler(async (event) => { const userinfo = verifyAccessToken(event); if (!userinfo) { diff --git a/apps/vben5/apps/backend-mock/api/menu/all.ts b/apps/vben5/apps/backend-mock/api/menu/all.ts index 580cee4f9..7923f7ca5 100644 --- a/apps/vben5/apps/backend-mock/api/menu/all.ts +++ b/apps/vben5/apps/backend-mock/api/menu/all.ts @@ -1,5 +1,7 @@ +import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; -import { unAuthorizedResponse } from '~/utils/response'; +import { MOCK_MENUS } from '~/utils/mock-data'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; export default eventHandler(async (event) => { const userinfo = verifyAccessToken(event); diff --git a/apps/vben5/apps/backend-mock/api/status.ts b/apps/vben5/apps/backend-mock/api/status.ts index 41773e1d5..43782095d 100644 --- a/apps/vben5/apps/backend-mock/api/status.ts +++ b/apps/vben5/apps/backend-mock/api/status.ts @@ -1,3 +1,6 @@ +import { eventHandler, getQuery, setResponseStatus } from 'h3'; +import { useResponseError } from '~/utils/response'; + export default eventHandler((event) => { const { status } = getQuery(event); setResponseStatus(event, Number(status)); diff --git a/apps/vben5/apps/backend-mock/api/system/dept/.post.ts b/apps/vben5/apps/backend-mock/api/system/dept/.post.ts index c529ea1bd..9a4896afa 100644 --- a/apps/vben5/apps/backend-mock/api/system/dept/.post.ts +++ b/apps/vben5/apps/backend-mock/api/system/dept/.post.ts @@ -1,3 +1,4 @@ +import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { sleep, diff --git a/apps/vben5/apps/backend-mock/api/system/dept/[id].delete.ts b/apps/vben5/apps/backend-mock/api/system/dept/[id].delete.ts index e48f051cc..eac0f5846 100644 --- a/apps/vben5/apps/backend-mock/api/system/dept/[id].delete.ts +++ b/apps/vben5/apps/backend-mock/api/system/dept/[id].delete.ts @@ -1,3 +1,4 @@ +import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { sleep, diff --git a/apps/vben5/apps/backend-mock/api/system/dept/[id].put.ts b/apps/vben5/apps/backend-mock/api/system/dept/[id].put.ts index aa55c0857..6805e1395 100644 --- a/apps/vben5/apps/backend-mock/api/system/dept/[id].put.ts +++ b/apps/vben5/apps/backend-mock/api/system/dept/[id].put.ts @@ -1,3 +1,4 @@ +import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { sleep, diff --git a/apps/vben5/apps/backend-mock/api/system/dept/list.ts b/apps/vben5/apps/backend-mock/api/system/dept/list.ts index ae819b622..a649a0d2f 100644 --- a/apps/vben5/apps/backend-mock/api/system/dept/list.ts +++ b/apps/vben5/apps/backend-mock/api/system/dept/list.ts @@ -1,4 +1,5 @@ import { faker } from '@faker-js/faker'; +import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; diff --git a/apps/vben5/apps/backend-mock/api/system/menu/list.ts b/apps/vben5/apps/backend-mock/api/system/menu/list.ts index 5328b2fdf..ce96bb14e 100644 --- a/apps/vben5/apps/backend-mock/api/system/menu/list.ts +++ b/apps/vben5/apps/backend-mock/api/system/menu/list.ts @@ -1,3 +1,4 @@ +import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { MOCK_MENU_LIST } from '~/utils/mock-data'; import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; diff --git a/apps/vben5/apps/backend-mock/api/system/menu/name-exists.ts b/apps/vben5/apps/backend-mock/api/system/menu/name-exists.ts index 5599c22b3..7d5551b3b 100644 --- a/apps/vben5/apps/backend-mock/api/system/menu/name-exists.ts +++ b/apps/vben5/apps/backend-mock/api/system/menu/name-exists.ts @@ -1,6 +1,7 @@ +import { eventHandler, getQuery } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { MOCK_MENU_LIST } from '~/utils/mock-data'; -import { unAuthorizedResponse } from '~/utils/response'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; const namesMap: Record = {}; diff --git a/apps/vben5/apps/backend-mock/api/system/menu/path-exists.ts b/apps/vben5/apps/backend-mock/api/system/menu/path-exists.ts index 64774f790..f3c3be997 100644 --- a/apps/vben5/apps/backend-mock/api/system/menu/path-exists.ts +++ b/apps/vben5/apps/backend-mock/api/system/menu/path-exists.ts @@ -1,6 +1,7 @@ +import { eventHandler, getQuery } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { MOCK_MENU_LIST } from '~/utils/mock-data'; -import { unAuthorizedResponse } from '~/utils/response'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; const pathMap: Record = { '/': 0 }; diff --git a/apps/vben5/apps/backend-mock/api/system/role/list.ts b/apps/vben5/apps/backend-mock/api/system/role/list.ts index 4d5f923e9..bad29a513 100644 --- a/apps/vben5/apps/backend-mock/api/system/role/list.ts +++ b/apps/vben5/apps/backend-mock/api/system/role/list.ts @@ -1,4 +1,5 @@ import { faker } from '@faker-js/faker'; +import { eventHandler, getQuery } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; import { getMenuIds, MOCK_MENU_LIST } from '~/utils/mock-data'; import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response'; diff --git a/apps/vben5/apps/backend-mock/api/system/user/list.ts b/apps/vben5/apps/backend-mock/api/system/user/list.ts new file mode 100644 index 000000000..21d44c8ba --- /dev/null +++ b/apps/vben5/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 = { + 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); +}); diff --git a/apps/vben5/apps/backend-mock/api/table/list.ts b/apps/vben5/apps/backend-mock/api/table/list.ts index 3e6f705b8..f81571e3b 100644 --- a/apps/vben5/apps/backend-mock/api/table/list.ts +++ b/apps/vben5/apps/backend-mock/api/table/list.ts @@ -1,6 +1,11 @@ import { faker } from '@faker-js/faker'; +import { eventHandler, getQuery } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; -import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response'; +import { + sleep, + unAuthorizedResponse, + usePageResponseSuccess, +} from '~/utils/response'; function generateMockDataList(count: number) { const dataList = []; @@ -44,30 +49,69 @@ export default eventHandler(async (event) => { await sleep(600); const { page, pageSize, sortBy, sortOrder } = getQuery(event); + // 规范化分页参数,处理 string[] + const pageRaw = Array.isArray(page) ? page[0] : page; + const pageSizeRaw = Array.isArray(pageSize) ? pageSize[0] : pageSize; + const pageNumber = Math.max( + 1, + Number.parseInt(String(pageRaw ?? '1'), 10) || 1, + ); + const pageSizeNumber = Math.min( + 100, + Math.max(1, Number.parseInt(String(pageSizeRaw ?? '10'), 10) || 10), + ); const listData = structuredClone(mockData); - if (sortBy && Reflect.has(listData[0], sortBy as string)) { + + // 规范化 query 入参,兼容 string[] + const sortKeyRaw = Array.isArray(sortBy) ? sortBy[0] : sortBy; + const sortOrderRaw = Array.isArray(sortOrder) ? sortOrder[0] : sortOrder; + // 检查 sortBy 是否是 listData 元素的合法属性键 + if ( + typeof sortKeyRaw === 'string' && + listData[0] && + Object.prototype.hasOwnProperty.call(listData[0], sortKeyRaw) + ) { + // 定义数组元素的类型 + type ItemType = (typeof listData)[0]; + const sortKey = sortKeyRaw as keyof ItemType; // 将 sortBy 断言为合法键 + const isDesc = sortOrderRaw === 'desc'; listData.sort((a, b) => { - if (sortOrder === 'asc') { - if (sortBy === 'price') { - return ( - Number.parseFloat(a[sortBy as string]) - - Number.parseFloat(b[sortBy as string]) - ); + const aValue = a[sortKey] as unknown; + const bValue = b[sortKey] as unknown; + + let result: number; + + if (typeof aValue === 'number' && typeof bValue === 'number') { + result = aValue - bValue; + } else if (aValue instanceof Date && bValue instanceof Date) { + result = aValue.getTime() - bValue.getTime(); + } else if (typeof aValue === 'boolean' && typeof bValue === 'boolean') { + if (aValue === bValue) { + result = 0; } else { - return a[sortBy as string] > b[sortBy as string] ? 1 : -1; + result = aValue ? 1 : -1; } } else { - if (sortBy === 'price') { - return ( - Number.parseFloat(b[sortBy as string]) - - Number.parseFloat(a[sortBy as string]) - ); - } else { - return a[sortBy as string] < b[sortBy as string] ? 1 : -1; - } + const aStr = String(aValue); + const bStr = String(bValue); + const aNum = Number(aStr); + const bNum = Number(bStr); + result = + Number.isFinite(aNum) && Number.isFinite(bNum) + ? aNum - bNum + : aStr.localeCompare(bStr, undefined, { + numeric: true, + sensitivity: 'base', + }); } + + return isDesc ? -result : result; }); } - return usePageResponseSuccess(page as string, pageSize as string, listData); + return usePageResponseSuccess( + String(pageNumber), + String(pageSizeNumber), + listData, + ); }); diff --git a/apps/vben5/apps/backend-mock/api/test.get.ts b/apps/vben5/apps/backend-mock/api/test.get.ts index ca4a500be..dc2ceef79 100644 --- a/apps/vben5/apps/backend-mock/api/test.get.ts +++ b/apps/vben5/apps/backend-mock/api/test.get.ts @@ -1 +1,3 @@ +import { defineEventHandler } from 'h3'; + export default defineEventHandler(() => 'Test get handler'); diff --git a/apps/vben5/apps/backend-mock/api/test.post.ts b/apps/vben5/apps/backend-mock/api/test.post.ts index 698cf211e..0e9e337a8 100644 --- a/apps/vben5/apps/backend-mock/api/test.post.ts +++ b/apps/vben5/apps/backend-mock/api/test.post.ts @@ -1 +1,3 @@ +import { defineEventHandler } from 'h3'; + export default defineEventHandler(() => 'Test post handler'); diff --git a/apps/vben5/apps/backend-mock/api/timezone/getTimezone.ts b/apps/vben5/apps/backend-mock/api/timezone/getTimezone.ts new file mode 100644 index 000000000..0cbcb6ec0 --- /dev/null +++ b/apps/vben5/apps/backend-mock/api/timezone/getTimezone.ts @@ -0,0 +1,12 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; +import { getTimezone } from '~/utils/timezone-utils'; + +export default eventHandler((event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + return useResponseSuccess(getTimezone()); +}); diff --git a/apps/vben5/apps/backend-mock/api/timezone/getTimezoneOptions.ts b/apps/vben5/apps/backend-mock/api/timezone/getTimezoneOptions.ts new file mode 100644 index 000000000..6c241864e --- /dev/null +++ b/apps/vben5/apps/backend-mock/api/timezone/getTimezoneOptions.ts @@ -0,0 +1,11 @@ +import { eventHandler } from 'h3'; +import { TIME_ZONE_OPTIONS } from '~/utils/mock-data'; +import { useResponseSuccess } from '~/utils/response'; + +export default eventHandler(() => { + const data = TIME_ZONE_OPTIONS.map((o) => ({ + label: `${o.timezone} (GMT${o.offset >= 0 ? `+${o.offset}` : o.offset})`, + value: o.timezone, + })); + return useResponseSuccess(data); +}); diff --git a/apps/vben5/apps/backend-mock/api/timezone/setTimezone.ts b/apps/vben5/apps/backend-mock/api/timezone/setTimezone.ts new file mode 100644 index 000000000..851e43512 --- /dev/null +++ b/apps/vben5/apps/backend-mock/api/timezone/setTimezone.ts @@ -0,0 +1,26 @@ +import { eventHandler, readBody, setResponseStatus } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { TIME_ZONE_OPTIONS } from '~/utils/mock-data'; +import { + unAuthorizedResponse, + useResponseError, + useResponseSuccess, +} from '~/utils/response'; +import { setTimezone } from '~/utils/timezone-utils'; + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + const body = await readBody<{ timezone?: unknown }>(event); + const timezone = + typeof body?.timezone === 'string' ? body.timezone : undefined; + const allowed = TIME_ZONE_OPTIONS.some((o) => o.timezone === timezone); + if (!timezone || !allowed) { + setResponseStatus(event, 400); + return useResponseError('Bad Request', 'Invalid timezone'); + } + setTimezone(timezone); + return useResponseSuccess({}); +}); diff --git a/apps/vben5/apps/backend-mock/api/upload.ts b/apps/vben5/apps/backend-mock/api/upload.ts index 1bb9e602d..436b63cbf 100644 --- a/apps/vben5/apps/backend-mock/api/upload.ts +++ b/apps/vben5/apps/backend-mock/api/upload.ts @@ -1,5 +1,6 @@ +import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; -import { unAuthorizedResponse } from '~/utils/response'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; export default eventHandler((event) => { const userinfo = verifyAccessToken(event); diff --git a/apps/vben5/apps/backend-mock/api/user/info.ts b/apps/vben5/apps/backend-mock/api/user/info.ts index cfa2346cb..138cb4331 100644 --- a/apps/vben5/apps/backend-mock/api/user/info.ts +++ b/apps/vben5/apps/backend-mock/api/user/info.ts @@ -1,5 +1,6 @@ +import { eventHandler } from 'h3'; import { verifyAccessToken } from '~/utils/jwt-utils'; -import { unAuthorizedResponse } from '~/utils/response'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; export default eventHandler((event) => { const userinfo = verifyAccessToken(event); diff --git a/apps/vben5/apps/backend-mock/middleware/1.api.ts b/apps/vben5/apps/backend-mock/middleware/1.api.ts index bad9a41a1..339cda4db 100644 --- a/apps/vben5/apps/backend-mock/middleware/1.api.ts +++ b/apps/vben5/apps/backend-mock/middleware/1.api.ts @@ -1,3 +1,4 @@ +import { defineEventHandler } from 'h3'; import { forbiddenResponse, sleep } from '~/utils/response'; export default defineEventHandler(async (event) => { diff --git a/apps/vben5/apps/backend-mock/nitro.config.ts b/apps/vben5/apps/backend-mock/nitro.config.ts index c0fc13e2e..9cc3acdb2 100644 --- a/apps/vben5/apps/backend-mock/nitro.config.ts +++ b/apps/vben5/apps/backend-mock/nitro.config.ts @@ -1,3 +1,5 @@ +import { defineNitroConfig } from 'nitropack/config'; + import errorHandler from './error'; process.env.COMPATIBILITY_DATE = new Date().toISOString(); diff --git a/apps/vben5/apps/backend-mock/package.json b/apps/vben5/apps/backend-mock/package.json index cc0b8d533..66961d4da 100644 --- a/apps/vben5/apps/backend-mock/package.json +++ b/apps/vben5/apps/backend-mock/package.json @@ -1,6 +1,6 @@ { "name": "@vben/backend-mock", - "version": "0.0.1", + "version": "5.7.0", "description": "", "private": true, "license": "MIT", diff --git a/apps/vben5/apps/backend-mock/routes/[...].ts b/apps/vben5/apps/backend-mock/routes/[...].ts index 99f544b66..5a22563dc 100644 --- a/apps/vben5/apps/backend-mock/routes/[...].ts +++ b/apps/vben5/apps/backend-mock/routes/[...].ts @@ -1,3 +1,5 @@ +import { defineEventHandler } from 'h3'; + export default defineEventHandler(() => { return `

Hello Vben Admin

diff --git a/apps/vben5/apps/backend-mock/tsconfig.json b/apps/vben5/apps/backend-mock/tsconfig.json index 43008af1c..4417b73d0 100644 --- a/apps/vben5/apps/backend-mock/tsconfig.json +++ b/apps/vben5/apps/backend-mock/tsconfig.json @@ -1,3 +1,11 @@ { - "extends": "./.nitro/types/tsconfig.json" + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@vben/tsconfig/node.json", + "compilerOptions": { + "paths": { + "~/*": ["./*"] + } + }, + "include": ["**/*.ts", "**/.*.ts"], + "exclude": ["node_modules", "dist", ".nitro"] } diff --git a/apps/vben5/apps/backend-mock/utils/cookie-utils.ts b/apps/vben5/apps/backend-mock/utils/cookie-utils.ts index 78f3aab73..187ce2f00 100644 --- a/apps/vben5/apps/backend-mock/utils/cookie-utils.ts +++ b/apps/vben5/apps/backend-mock/utils/cookie-utils.ts @@ -1,5 +1,7 @@ import type { EventHandlerRequest, H3Event } from 'h3'; +import { deleteCookie, getCookie, setCookie } from 'h3'; + export function clearRefreshTokenCookie(event: H3Event) { deleteCookie(event, 'jwt', { httpOnly: true, diff --git a/apps/vben5/apps/backend-mock/utils/jwt-utils.ts b/apps/vben5/apps/backend-mock/utils/jwt-utils.ts index 8cfc68436..718583070 100644 --- a/apps/vben5/apps/backend-mock/utils/jwt-utils.ts +++ b/apps/vben5/apps/backend-mock/utils/jwt-utils.ts @@ -1,8 +1,11 @@ import type { EventHandlerRequest, H3Event } from 'h3'; +import type { UserInfo } from './mock-data'; + +import { getHeader } from 'h3'; import jwt from 'jsonwebtoken'; -import { UserInfo } from './mock-data'; +import { MOCK_USERS } from './mock-data'; // TODO: Replace with your own secret key const ACCESS_TOKEN_SECRET = 'access_token_secret'; @@ -31,12 +34,22 @@ export function verifyAccessToken( return null; } - const token = authHeader.split(' ')[1]; + const tokenParts = authHeader.split(' '); + if (tokenParts.length !== 2) { + return null; + } + const token = tokenParts[1] as string; try { - const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET) as UserPayload; + const decoded = jwt.verify( + token, + ACCESS_TOKEN_SECRET, + ) as unknown as UserPayload; const username = decoded.username; const user = MOCK_USERS.find((item) => item.username === username); + if (!user) { + return null; + } const { password: _pwd, ...userinfo } = user; return userinfo; } catch { @@ -50,7 +63,12 @@ export function verifyRefreshToken( try { const decoded = jwt.verify(token, REFRESH_TOKEN_SECRET) as UserPayload; const username = decoded.username; - const user = MOCK_USERS.find((item) => item.username === username); + const user = MOCK_USERS.find( + (item) => item.username === username, + ) as UserInfo; + if (!user) { + return null; + } const { password: _pwd, ...userinfo } = user; return userinfo; } catch { diff --git a/apps/vben5/apps/backend-mock/utils/mock-data.ts b/apps/vben5/apps/backend-mock/utils/mock-data.ts index 192f30a00..ee38e8ef9 100644 --- a/apps/vben5/apps/backend-mock/utils/mock-data.ts +++ b/apps/vben5/apps/backend-mock/utils/mock-data.ts @@ -7,6 +7,11 @@ export interface UserInfo { homePath?: string; } +export interface TimezoneOption { + offset: number; + timezone: string; +} + export const MOCK_USERS: UserInfo[] = [ { id: 0, @@ -276,7 +281,7 @@ export const MOCK_MENU_LIST = [ children: [ { id: 20_401, - pid: 201, + pid: 202, name: 'SystemDeptCreate', status: 1, type: 'button', @@ -285,7 +290,7 @@ export const MOCK_MENU_LIST = [ }, { id: 20_402, - pid: 201, + pid: 202, name: 'SystemDeptEdit', status: 1, type: 'button', @@ -294,7 +299,7 @@ export const MOCK_MENU_LIST = [ }, { id: 20_403, - pid: 201, + pid: 202, name: 'SystemDeptDelete', status: 1, type: 'button', @@ -388,3 +393,29 @@ export function getMenuIds(menus: any[]) { }); return ids; } + +/** + * 时区选项 + */ +export const TIME_ZONE_OPTIONS: TimezoneOption[] = [ + { + offset: -5, + timezone: 'America/New_York', + }, + { + offset: 0, + timezone: 'Europe/London', + }, + { + offset: 8, + timezone: 'Asia/Shanghai', + }, + { + offset: 9, + timezone: 'Asia/Tokyo', + }, + { + offset: 9, + timezone: 'Asia/Seoul', + }, +]; diff --git a/apps/vben5/apps/backend-mock/utils/response.ts b/apps/vben5/apps/backend-mock/utils/response.ts index 2a5a908f2..2d4242e98 100644 --- a/apps/vben5/apps/backend-mock/utils/response.ts +++ b/apps/vben5/apps/backend-mock/utils/response.ts @@ -1,5 +1,7 @@ import type { EventHandlerRequest, H3Event } from 'h3'; +import { setResponseStatus } from 'h3'; + export function useResponseSuccess(data: T) { return { code: 0, diff --git a/apps/vben5/apps/backend-mock/utils/timezone-utils.ts b/apps/vben5/apps/backend-mock/utils/timezone-utils.ts new file mode 100644 index 000000000..da35f920f --- /dev/null +++ b/apps/vben5/apps/backend-mock/utils/timezone-utils.ts @@ -0,0 +1,9 @@ +let mockTimeZone: null | string = null; + +export const setTimezone = (timeZone: string) => { + mockTimeZone = timeZone; +}; + +export const getTimezone = () => { + return mockTimeZone; +}; diff --git a/apps/vben5/apps/web-antd/index.html b/apps/vben5/apps/web-antd/index.html index 480eb84de..e3b9ae5c5 100644 --- a/apps/vben5/apps/web-antd/index.html +++ b/apps/vben5/apps/web-antd/index.html @@ -12,7 +12,7 @@ content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0" /> - <%= VITE_APP_TITLE %> + %VITE_APP_TITLE%