diff --git a/deploy/fba/fba-ui-src/.browserslistrc b/deploy/fba/fba-ui-src/.browserslistrc
new file mode 100644
index 0000000..dc3bc09
--- /dev/null
+++ b/deploy/fba/fba-ui-src/.browserslistrc
@@ -0,0 +1,4 @@
+> 1%
+last 2 versions
+not dead
+not ie 11
diff --git a/deploy/fba/fba-ui-src/.commitlintrc.js b/deploy/fba/fba-ui-src/.commitlintrc.js
new file mode 100644
index 0000000..02e33fa
--- /dev/null
+++ b/deploy/fba/fba-ui-src/.commitlintrc.js
@@ -0,0 +1 @@
+export { default } from '@vben/commitlint-config';
diff --git a/deploy/fba/fba-ui-src/.dockerignore b/deploy/fba/fba-ui-src/.dockerignore
new file mode 100644
index 0000000..52b833a
--- /dev/null
+++ b/deploy/fba/fba-ui-src/.dockerignore
@@ -0,0 +1,7 @@
+node_modules
+.git
+.gitignore
+*.md
+dist
+.turbo
+dist.zip
diff --git a/deploy/fba/fba-ui-src/.editorconfig b/deploy/fba/fba-ui-src/.editorconfig
new file mode 100644
index 0000000..179aec6
--- /dev/null
+++ b/deploy/fba/fba-ui-src/.editorconfig
@@ -0,0 +1,18 @@
+root = true
+
+[*]
+charset=utf-8
+end_of_line=lf
+insert_final_newline=true
+indent_style=space
+indent_size=2
+max_line_length = 100
+trim_trailing_whitespace = true
+quote_type = single
+
+[*.{yml,yaml,json}]
+indent_style = space
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/deploy/fba/fba-ui-src/.gitattributes b/deploy/fba/fba-ui-src/.gitattributes
new file mode 100644
index 0000000..d4e5bd3
--- /dev/null
+++ b/deploy/fba/fba-ui-src/.gitattributes
@@ -0,0 +1,11 @@
+# https://docs.github.com/cn/get-started/getting-started-with-git/configuring-git-to-handle-line-endings
+
+# Automatically normalize line endings (to LF) for all text-based files.
+* text=auto eol=lf
+
+# Declare files that will always have CRLF line endings on checkout.
+*.{cmd,[cC][mM][dD]} text eol=crlf
+*.{bat,[bB][aA][tT]} text eol=crlf
+
+# Denote all files that are truly binary and should not be modified.
+*.{ico,png,jpg,jpeg,gif,webp,svg,woff,woff2} binary
\ No newline at end of file
diff --git a/deploy/fba/fba-ui-src/.gitconfig b/deploy/fba/fba-ui-src/.gitconfig
new file mode 100644
index 0000000..4b28a69
--- /dev/null
+++ b/deploy/fba/fba-ui-src/.gitconfig
@@ -0,0 +1,2 @@
+[core]
+ ignorecase = false
diff --git a/deploy/fba/fba-ui-src/.gitignore b/deploy/fba/fba-ui-src/.gitignore
new file mode 100644
index 0000000..e5537be
--- /dev/null
+++ b/deploy/fba/fba-ui-src/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+*.local
diff --git a/deploy/fba/fba-ui-src/.lintstagedrc.mjs b/deploy/fba/fba-ui-src/.lintstagedrc.mjs
new file mode 100644
index 0000000..94b0192
--- /dev/null
+++ b/deploy/fba/fba-ui-src/.lintstagedrc.mjs
@@ -0,0 +1,20 @@
+export default {
+ '*.md': ['prettier --cache --ignore-unknown --write'],
+ '*.vue': [
+ 'prettier --write',
+ 'eslint --cache --fix',
+ 'stylelint --fix --allow-empty-input',
+ ],
+ '*.{js,jsx,ts,tsx}': [
+ 'prettier --cache --ignore-unknown --write',
+ 'eslint --cache --fix',
+ ],
+ '*.{scss,less,styl,html,vue,css}': [
+ 'prettier --cache --ignore-unknown --write',
+ 'stylelint --fix --allow-empty-input',
+ ],
+ 'package.json': ['prettier --cache --write'],
+ '{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': [
+ 'prettier --cache --write--parser json',
+ ],
+};
diff --git a/deploy/fba/fba-ui-src/.node-version b/deploy/fba/fba-ui-src/.node-version
new file mode 100644
index 0000000..b832e40
--- /dev/null
+++ b/deploy/fba/fba-ui-src/.node-version
@@ -0,0 +1 @@
+24.16.0
diff --git a/deploy/fba/fba-ui-src/.npmrc b/deploy/fba/fba-ui-src/.npmrc
new file mode 100644
index 0000000..7549542
--- /dev/null
+++ b/deploy/fba/fba-ui-src/.npmrc
@@ -0,0 +1 @@
+registry=https://registry.npmmirror.com
diff --git a/deploy/fba/fba-ui-src/.stylelintignore b/deploy/fba/fba-ui-src/.stylelintignore
new file mode 100644
index 0000000..3adb33b
--- /dev/null
+++ b/deploy/fba/fba-ui-src/.stylelintignore
@@ -0,0 +1,8 @@
+dist
+public
+__tests__
+coverage
+.codex
+.claude
+.agent
+.agents
diff --git a/deploy/fba/fba-ui-src/Dockerfile b/deploy/fba/fba-ui-src/Dockerfile
new file mode 100644
index 0000000..a630235
--- /dev/null
+++ b/deploy/fba/fba-ui-src/Dockerfile
@@ -0,0 +1,18 @@
+FROM guergeiro/pnpm:lts-latest-slim AS build
+
+WORKDIR /fba_ui
+
+COPY . .
+
+RUN pnpm install \
+ && pnpm build
+
+FROM nginx
+
+COPY scripts/deploy/nginx.conf /etc/nginx/nginx.conf
+
+COPY --from=build /fba_ui/apps/web-antdv-next/dist /var/www/fba_ui
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/deploy/fba/fba-ui-src/LICENSE b/deploy/fba/fba-ui-src/LICENSE
new file mode 100644
index 0000000..5b8539b
--- /dev/null
+++ b/deploy/fba/fba-ui-src/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 FastAPI Practices
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/deploy/fba/fba-ui-src/README.md b/deploy/fba/fba-ui-src/README.md
new file mode 100644
index 0000000..a417493
--- /dev/null
+++ b/deploy/fba/fba-ui-src/README.md
@@ -0,0 +1,23 @@
+# FastAPI Best Architecture UI
+
+Front-end Implementation of the [FastAPI Best Architecture](https://github.com/fastapi-practices/fastapi_best_architecture)
+
+## Help
+
+For more details, please check the [official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/frontend/summary/quick-start.html)
+
+## Contributors
+
+
+
+
+
+## Special thanks
+
+- [Vue.js](https://cn.vuejs.org/guide/introduction.html)
+- [Vben Admin](https://www.vben.pro/)
+- ...
+
+## License
+
+This project is licensed under the terms of the [MIT](https://github.com/fastapi-practices/fba_ui/blob/master/LICENSE) license
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/.env b/deploy/fba/fba-ui-src/apps/web-antdv-next/.env
new file mode 100644
index 0000000..f42dab9
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/.env
@@ -0,0 +1,8 @@
+# 应用标题
+VITE_APP_TITLE=FBA UI
+
+# 应用命名空间,用于缓存、store等功能的前缀,确保隔离
+VITE_APP_NAMESPACE=fba-ui
+
+# 对store进行加密的密钥,在将store持久化到localStorage时会使用该密钥进行加密
+VITE_APP_STORE_SECURE_KEY=please-replace-me-with-your-own-key
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/.env.analyze b/deploy/fba/fba-ui-src/apps/web-antdv-next/.env.analyze
new file mode 100644
index 0000000..d3df863
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/.env.analyze
@@ -0,0 +1,7 @@
+# public path
+VITE_BASE=/
+
+# Basic interface address SPA
+VITE_GLOB_API_URL=http://localhost:8000
+
+VITE_VISUALIZER=true
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/.env.development b/deploy/fba/fba-ui-src/apps/web-antdv-next/.env.development
new file mode 100644
index 0000000..7897ad3
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/.env.development
@@ -0,0 +1,16 @@
+# 端口号
+VITE_PORT=5173
+
+VITE_BASE=/
+
+# 接口地址
+VITE_GLOB_API_URL=http://localhost:8000
+
+# 是否开启 Nitro Mock服务,true 为开启,false 为关闭
+VITE_NITRO_MOCK=false
+
+# 是否打开 devtools,true 为打开,false 为关闭
+VITE_DEVTOOLS=false
+
+# 是否注入全局loading
+VITE_INJECT_APP_LOADING=true
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/.env.production b/deploy/fba/fba-ui-src/apps/web-antdv-next/.env.production
new file mode 100644
index 0000000..403946b
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/.env.production
@@ -0,0 +1,19 @@
+VITE_BASE=/
+
+# 接口地址
+VITE_GLOB_API_URL=https://fba.wu-clan.site
+
+# 是否开启压缩,可以设置为 none, brotli, gzip
+VITE_COMPRESS=gzip
+
+# 是否开启 PWA
+VITE_PWA=false
+
+# vue-router 的模式
+VITE_ROUTER_HISTORY=hash
+
+# 是否注入全局loading
+VITE_INJECT_APP_LOADING=true
+
+# 打包后是否生成dist.zip
+VITE_ARCHIVER=false
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/index.html b/deploy/fba/fba-ui-src/apps/web-antdv-next/index.html
new file mode 100644
index 0000000..a9081c6
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %VITE_APP_TITLE%
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/package.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/package.json
new file mode 100644
index 0000000..a00cda0
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "@vben/web-antdv-next",
+ "version": "5.7.0",
+ "homepage": "https://vben.pro",
+ "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/vbenjs/vue-vben-admin.git",
+ "directory": "apps/web-antdv-next"
+ },
+ "license": "MIT",
+ "author": {
+ "name": "vben",
+ "email": "ann.vben@gmail.com",
+ "url": "https://github.com/anncwb"
+ },
+ "type": "module",
+ "scripts": {
+ "build": "pnpm vite build --mode production",
+ "build:analyze": "pnpm vite build --mode analyze",
+ "dev": "pnpm vite --mode development",
+ "preview": "vite preview",
+ "typecheck": "vue-tsc --noEmit --skipLibCheck"
+ },
+ "imports": {
+ "#/*": "./src/*"
+ },
+ "dependencies": {
+ "@vben/access": "workspace:*",
+ "@vben/common-ui": "workspace:*",
+ "@vben/constants": "workspace:*",
+ "@vben/hooks": "workspace:*",
+ "@vben/icons": "workspace:*",
+ "@vben/layouts": "workspace:*",
+ "@vben/locales": "workspace:*",
+ "@vben/plugins": "workspace:*",
+ "@vben/preferences": "workspace:*",
+ "@vben/request": "workspace:*",
+ "@vben/stores": "workspace:*",
+ "@vben/styles": "workspace:*",
+ "@vben/types": "workspace:*",
+ "@vben/utils": "workspace:*",
+ "@vueuse/core": "catalog:",
+ "antdv-next": "catalog:",
+ "croner": "catalog:",
+ "dayjs": "catalog:",
+ "mitt": "catalog:",
+ "pinia": "catalog:",
+ "socket.io-client": "catalog:",
+ "vue": "catalog:",
+ "vue-router": "catalog:"
+ }
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/public/favicon.ico b/deploy/fba/fba-ui-src/apps/web-antdv-next/public/favicon.ico
new file mode 100644
index 0000000..c2c5a4f
Binary files /dev/null and b/deploy/fba/fba-ui-src/apps/web-antdv-next/public/favicon.ico differ
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/adapter/component/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/adapter/component/index.ts
new file mode 100644
index 0000000..ebd6909
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/adapter/component/index.ts
@@ -0,0 +1,788 @@
+/**
+ * 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
+ * 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
+ */
+
+/* eslint-disable vue/one-component-per-file */
+
+import type {
+ AutoCompleteProps,
+ ButtonProps,
+ CascaderProps,
+ CheckboxGroupProps,
+ CheckboxProps,
+ DatePickerProps,
+ DividerProps,
+ InputNumberProps,
+ InputProps,
+ MentionsProps,
+ RadioGroupProps,
+ RadioProps,
+ RangePickerProps,
+ RateProps,
+ SelectProps,
+ SpaceProps,
+ SwitchProps,
+ TextAreaProps,
+ TimePickerProps,
+ TreeSelectProps,
+ UploadChangeParam,
+ UploadFile,
+ UploadProps,
+} from 'antdv-next';
+
+import type { Component, Ref } from 'vue';
+
+import type {
+ ApiComponentSharedProps,
+ BaseFormComponentType,
+ CollapsibleParamsProps,
+ IconPickerProps,
+} from '@vben/common-ui';
+import type { Sortable } from '@vben/hooks';
+import type { TipTapProps } from '@vben/plugins/tiptap';
+import type { Recordable } from '@vben/types';
+
+import {
+ computed,
+ defineAsyncComponent,
+ defineComponent,
+ h,
+ nextTick,
+ onMounted,
+ onUnmounted,
+ ref,
+ render,
+ unref,
+ watch,
+} from 'vue';
+
+import {
+ ApiComponent,
+ globalShareState,
+ IconPicker,
+ VbenCollapsibleParams,
+ VCropper,
+} from '@vben/common-ui';
+import { useSortable } from '@vben/hooks';
+import { IconifyIcon } from '@vben/icons';
+import { $t } from '@vben/locales';
+import { VbenTiptap } from '@vben/plugins/tiptap';
+import { isEmpty } from '@vben/utils';
+
+import { message, Modal, notification } from 'antdv-next';
+
+import { upload_file } from '#/api';
+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('antdv-next/dist/auto-complete/index'),
+);
+const Button = defineAsyncComponent(
+ () => import('antdv-next/dist/button/index'),
+);
+const Checkbox = defineAsyncComponent(
+ () => import('antdv-next/dist/checkbox/index'),
+);
+const CheckboxGroup = defineAsyncComponent(() =>
+ import('antdv-next/dist/checkbox/index').then((res) => res.CheckboxGroup),
+);
+const DatePicker = defineAsyncComponent(
+ () => import('antdv-next/dist/date-picker/index'),
+);
+const Divider = defineAsyncComponent(
+ () => import('antdv-next/dist/divider/index'),
+);
+const Input = defineAsyncComponent(() => import('antdv-next/dist/input/index'));
+const InputNumber = defineAsyncComponent(
+ () => import('antdv-next/dist/input-number/index'),
+);
+const InputPassword = defineAsyncComponent(() =>
+ import('antdv-next/dist/input/index').then((res) => res.InputPassword),
+);
+const Mentions = defineAsyncComponent(
+ () => import('antdv-next/dist/mentions/index'),
+);
+const Radio = defineAsyncComponent(() => import('antdv-next/dist/radio/index'));
+const RadioGroup = defineAsyncComponent(() =>
+ import('antdv-next/dist/radio/index').then((res) => res.RadioGroup),
+);
+const RangePicker = defineAsyncComponent(() =>
+ import('antdv-next/dist/date-picker/index').then(
+ (res) => res.DateRangePicker,
+ ),
+);
+const Rate = defineAsyncComponent(() => import('antdv-next/dist/rate/index'));
+const Select = defineAsyncComponent(
+ () => import('antdv-next/dist/select/index'),
+);
+const Space = defineAsyncComponent(() => import('antdv-next/dist/space/index'));
+const Switch = defineAsyncComponent(
+ () => import('antdv-next/dist/switch/index'),
+);
+const Textarea = defineAsyncComponent(
+ () => import('antdv-next/dist/input/TextArea'),
+);
+const TimePicker = defineAsyncComponent(
+ () => import('antdv-next/dist/time-picker/index'),
+);
+const TreeSelect = defineAsyncComponent(
+ () => import('antdv-next/dist/tree-select/index'),
+);
+const Cascader = defineAsyncComponent(
+ () => import('antdv-next/dist/cascader/index'),
+);
+const Upload = defineAsyncComponent(
+ () => import('antdv-next/dist/upload/index'),
+);
+const Image = defineAsyncComponent(() => import('antdv-next/dist/image/index'));
+const PreviewGroup = defineAsyncComponent(() =>
+ import('antdv-next/dist/image/index').then((res) => res.ImagePreviewGroup),
+);
+
+const withDefaultPlaceholder = (
+ component: Component,
+ type: 'input' | 'select',
+ componentProps: Recordable = {},
+) => {
+ return defineComponent({
+ name: component.name,
+ inheritAttrs: false,
+ setup: (props: any, { attrs, expose, slots }) => {
+ const placeholder =
+ props?.placeholder ||
+ attrs?.placeholder ||
+ $t(`ui.placeholder.${type}`);
+ // 透传组件暴露的方法
+ const innerRef = ref();
+ expose(
+ new Proxy(
+ {},
+ {
+ get: (_target, key) => innerRef.value?.[key],
+ has: (_target, key) => key in (innerRef.value || {}),
+ },
+ ),
+ );
+ return () =>
+ h(
+ component,
+ { ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
+ slots,
+ );
+ },
+ });
+};
+
+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;
+ }
+ }
+ 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,
+ ),
+ };
+}
+
+/**
+ * 获取文件的 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,
+ open: Ref,
+ fileList: Ref,
+) {
+ // 非图片文件直接打开链接
+ if (!isImageFile(file)) {
+ const url = file.url || file.preview;
+ if (url) {
+ window.open(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 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: {
+ open: open.value,
+ current: currentIndex,
+ onOpenChange: (value: boolean) => {
+ open.value = value;
+ if (!value) {
+ setTimeout(() => {
+ if (!isUnmounted && container) {
+ isUnmounted = true;
+ render(null, container);
+ container.remove();
+ }
+ }, 300);
+ }
+ },
+ },
+ },
+ () =>
+ imageFiles.map((imgFile) =>
+ h(ImageComponent, {
+ key: imgFile.uid,
+ src: imgFile.url || imgFile.preview,
+ }),
+ ),
+ );
+ };
+ },
+ };
+
+ 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 open = ref(true);
+ const cropperRef = ref | null>(null);
+
+ function 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'),
+ destroyOnHidden: 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();
+ },
+ },
+ () =>
+ h(VCropper, {
+ ref: (ref: any) => (cropperRef.value = ref),
+ img: objectUrl as string,
+ aspectRatio,
+ }),
+ );
+ };
+ },
+ };
+
+ render(h(CropperWrapper), container);
+ });
+}
+
+/**
+ * 带预览功能的上传组件
+ */
+function withPreviewUpload() {
+ return defineComponent({
+ name: Upload.name,
+ emits: ['update:modelValue'],
+ 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 listType = attrs?.listType || attrs?.['list-type'] || 'text';
+ const fileList = ref(
+ attrs?.fileList || attrs?.['file-list'] || [],
+ );
+
+ const maxSize = computed(() => attrs?.maxSize ?? attrs?.['max-size']);
+ const aspectRatio = computed(
+ () => attrs?.aspectRatio ?? attrs?.['aspect-ratio'],
+ );
+
+ async function handleBeforeUpload(
+ 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 &&
+ originFileList[0] &&
+ isImageFile(file)
+ ) {
+ file.status = 'removed';
+ const blob = await cropImage(originFileList[0], aspectRatio.value);
+ if (!blob) {
+ throw new Error($t('ui.crop.errorTip'));
+ }
+ return blob;
+ }
+
+ return attrs.beforeUpload?.(file) ?? true;
+ }
+
+ function handleChange(event: UploadChangeParam) {
+ try {
+ attrs.handleChange?.(event);
+ attrs.onHandleChange?.(event);
+ } catch (error) {
+ console.error(error);
+ }
+ fileList.value = event.fileList.filter(
+ (file) => file.status !== 'removed',
+ );
+ emit(
+ 'update:modelValue',
+ event.fileList?.length ? fileList.value : undefined,
+ );
+ }
+
+ function handlePreview(file: UploadFile) {
+ previewVisible.value = true;
+ return previewImage(file, previewVisible, fileList);
+ }
+
+ function renderUploadButton() {
+ if (attrs.disabled) return null;
+ return isEmpty(slots)
+ ? createDefaultUploadSlots(listType, placeholder)
+ : slots;
+ }
+
+ // 拖拽排序
+ 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) => {
+ fileList.value = res;
+ },
+ );
+
+ onMounted(initSortable);
+ onUnmounted(() => {
+ sortableInstance.value?.destroy();
+ removeDragStyle();
+ });
+
+ return () =>
+ h(
+ 'div',
+ { 'data-upload-id': uploadId, class: 'w-full' },
+ h(
+ Upload,
+ {
+ ...props,
+ ...attrs,
+ fileList: fileList.value,
+ beforeUpload: handleBeforeUpload,
+ onChange: handleChange,
+ onPreview: handlePreview,
+ },
+ renderUploadButton() as any,
+ ),
+ );
+ },
+ });
+}
+
+// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
+export type ComponentType =
+ | 'ApiCascader'
+ | 'ApiSelect'
+ | 'ApiTreeSelect'
+ | 'AutoComplete'
+ | 'Cascader'
+ | 'Checkbox'
+ | 'CheckboxGroup'
+ | 'CollapsibleParams'
+ | 'DatePicker'
+ | 'DefaultButton'
+ | 'Divider'
+ | 'IconPicker'
+ | 'Input'
+ | 'InputNumber'
+ | 'InputPassword'
+ | 'Mentions'
+ | 'PrimaryButton'
+ | 'Radio'
+ | 'RadioGroup'
+ | 'RangePicker'
+ | 'Rate'
+ | 'RichEditor'
+ | 'Select'
+ | 'Space'
+ | 'Switch'
+ | 'Textarea'
+ | 'TimePicker'
+ | 'TreeSelect'
+ | '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;
+ CollapsibleParams: CollapsibleParamsProps;
+ 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;
+ RichEditor: TipTapProps;
+ Select: SelectProps;
+ Space: SpaceProps;
+ Switch: SwitchProps;
+ Textarea: TextAreaProps;
+ TimePicker: TimePickerProps;
+ TreeSelect: TreeSelectProps;
+ Upload: AdapterUploadProps;
+}
+
+async function initComponentAdapter() {
+ const components: Partial> = {
+ // 如果你的组件体积比较大,可以使用异步加载
+ // Button: () =>
+ // import('xxx').then((res) => res.Button),
+
+ ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', {
+ component: Cascader,
+ fieldNames: { label: 'label', value: 'value', children: 'children' },
+ loadingSlot: 'suffixIcon',
+ modelPropName: 'value',
+ visibleEvent: 'onOpenChange',
+ }),
+ ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
+ component: Select,
+ loadingSlot: 'suffixIcon',
+ modelPropName: 'value',
+ visibleEvent: 'onOpenChange',
+ }),
+ ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
+ component: TreeSelect,
+ fieldNames: { label: 'label', value: 'value', children: 'children' },
+ loadingSlot: 'suffixIcon',
+ modelPropName: 'value',
+ optionsPropName: 'treeData',
+ visibleEvent: 'onOpenChange',
+ }),
+ AutoComplete,
+ Cascader,
+ Checkbox,
+ CheckboxGroup,
+ DatePicker,
+ // 自定义默认按钮
+ DefaultButton: (props, { attrs, slots }) => {
+ return h(Button, { ...props, attrs, type: 'default' }, slots);
+ },
+ Divider,
+ IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
+ iconSlot: 'addonAfter',
+ inputComponent: Input,
+ modelValueProp: 'value',
+ }),
+ Input: withDefaultPlaceholder(Input, 'input'),
+ InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
+ style: { width: '100%' },
+ }),
+ InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
+ Mentions: withDefaultPlaceholder(Mentions, 'input'),
+ // 自定义主要按钮
+ PrimaryButton: (props, { attrs, slots }) => {
+ return h(Button, { ...props, attrs, type: 'primary' }, slots);
+ },
+ Radio,
+ RadioGroup,
+ RangePicker,
+ Rate,
+ RichEditor: withDefaultPlaceholder(VbenTiptap, 'input', {
+ imageUpload: {
+ upload: (file: any, onProgress: any) => {
+ return new Promise((resolve, reject) => {
+ upload_file({
+ file,
+ onProgress({ percent }) {
+ onProgress?.(percent);
+ },
+ onSuccess(response) {
+ // 从响应中提取图片URL
+ resolve(response?.data?.url ?? response?.url ?? '');
+ },
+ onError() {
+ reject(new Error($t('ui.tiptap.upload.uploadFailed')));
+ },
+ });
+ });
+ },
+ },
+ }),
+ Select: withDefaultPlaceholder(Select, 'select'),
+ Space,
+ Switch,
+ Textarea: withDefaultPlaceholder(Textarea, 'input'),
+ TimePicker,
+ TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
+ Upload: withPreviewUpload(),
+ CollapsibleParams: VbenCollapsibleParams,
+ };
+
+ // 将组件注册到全局共享状态中
+ globalShareState.setComponents(components);
+
+ // 定义全局共享状态中的消息提示
+ globalShareState.defineMessage({
+ // 复制成功消息提示
+ copyPreferencesSuccess: (title, content) => {
+ notification.success({
+ description: content,
+ title,
+ placement: 'bottomRight',
+ });
+ },
+ });
+}
+
+export { initComponentAdapter };
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/adapter/form.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/adapter/form.ts
new file mode 100644
index 0000000..3d06294
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/adapter/form.ts
@@ -0,0 +1,48 @@
+import type {
+ VbenFormProps as FormProps,
+ VbenFormSchema as FormSchema,
+} from '@vben/common-ui';
+
+import type { ComponentPropsMap, ComponentType } from './component';
+
+import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+async function initSetupVbenForm() {
+ setupVbenForm({
+ config: {
+ // ant design vue组件库默认都是 v-model:value
+ baseModelPropName: 'value',
+
+ // 一些组件是 v-model:checked 或者 v-model:fileList
+ modelPropNameMap: {
+ Checkbox: 'checked',
+ Radio: 'checked',
+ Switch: 'checked',
+ Upload: 'fileList',
+ },
+ },
+ defineRules: {
+ // 输入项目必填国际化适配
+ required: (value, _params, ctx) => {
+ if (value === undefined || value === null || value.length === 0) {
+ return $t('ui.formRules.required', [ctx.label]);
+ }
+ return true;
+ },
+ // 选择项目必填国际化适配
+ selectRequired: (value, _params, ctx) => {
+ if (value === undefined || value === null) {
+ return $t('ui.formRules.selectRequired', [ctx.label]);
+ }
+ return true;
+ },
+ },
+ });
+}
+const useVbenForm = useForm;
+
+export { initSetupVbenForm, useVbenForm, z };
+
+export type VbenFormSchema = FormSchema;
+export type VbenFormProps = FormProps;
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/adapter/vxe-table.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/adapter/vxe-table.ts
new file mode 100644
index 0000000..eee4d02
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/adapter/vxe-table.ts
@@ -0,0 +1,318 @@
+import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
+import type { Recordable } from '@vben/types';
+
+import type { ComponentPropsMap, ComponentType } from './component';
+
+import { h } from 'vue';
+
+import { IconifyIcon } from '@vben/icons';
+import { $te } from '@vben/locales';
+import {
+ setupVbenVxeTable,
+ useVbenVxeGrid as useGrid,
+} from '@vben/plugins/vxe-table';
+import { get, isFunction, isString } from '@vben/utils';
+
+import { objectOmit } from '@vueuse/core';
+import { Button, Dropdown, Image, Popconfirm, Switch, Tag } from 'antdv-next';
+
+import { $t } from '#/locales';
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+import { useVbenForm } from './form';
+
+setupVbenVxeTable({
+ configVxeTable: (vxeUI) => {
+ vxeUI.setConfig({
+ grid: {
+ align: 'center',
+ border: false,
+ columnConfig: {
+ resizable: true,
+ },
+ minHeight: 180,
+ formConfig: {
+ // 全局禁用vxe-table的表单配置,使用formOptions
+ enabled: false,
+ },
+ proxyConfig: {
+ autoLoad: true,
+ response: {
+ result: 'items',
+ total: 'total',
+ list: '',
+ },
+ showActiveMsg: true,
+ showResponseMsg: false,
+ },
+ round: true,
+ showOverflow: true,
+ size: 'small',
+ } as VxeTableGridOptions,
+ });
+
+ // 表格配置项可以用 cellRender: { name: 'CellImage' },
+ vxeUI.renderer.add('CellImage', {
+ renderTableDefault(renderOpts, params) {
+ const { props } = renderOpts;
+ const { column, row } = params;
+ return h(Image, { src: row[column.field], ...props });
+ },
+ });
+
+ // 表格配置项可以用 cellRender: { name: 'CellLink' },
+ vxeUI.renderer.add('CellLink', {
+ renderTableDefault(renderOpts) {
+ const { props } = renderOpts;
+ return h(
+ Button,
+ { size: 'small', type: 'link' },
+ { default: () => props?.text },
+ );
+ },
+ });
+
+ // 单元格渲染:Tag
+ vxeUI.renderer.add('CellTag', {
+ renderTableDefault({ options, props }, { column, row }) {
+ const value = get(row, column.field);
+ // const tagOptions = options ?? [
+ // { color: 'success', label: $t('common.enabled'), value: 1 },
+ // { color: 'error', label: $t('common.disabled'), value: 0 },
+ // ];
+ const tagOptions = options ?? getDictOptions(DictEnum.SYS_STATUS);
+ const tagItem = tagOptions.find((item) => item.value === value);
+ return h(
+ Tag,
+ {
+ ...props,
+ ...objectOmit(tagItem ?? {}, ['label']),
+ },
+ { default: () => tagItem?.label ?? value },
+ );
+ },
+ });
+
+ // 单元格渲染器:Switch
+ vxeUI.renderer.add('CellSwitch', {
+ renderTableDefault({ attrs, props }, { column, row }) {
+ const loadingKey = `__loading_${column.field}`;
+ const finallyProps = {
+ checkedChildren: $t('common.enabled'),
+ checkedValue: props?.checkedValue || 1,
+ unCheckedChildren: $t('common.disabled'),
+ unCheckedValue: props?.unCheckedValue || 0,
+ ...props,
+ checked: row[column.field],
+ loading: row[loadingKey] ?? false,
+ 'onUpdate:checked': onChange,
+ };
+ async function onChange(newVal: any) {
+ row[loadingKey] = true;
+ try {
+ const result = await attrs?.beforeChange?.(newVal, row);
+ if (result !== false) {
+ row[column.field] = newVal;
+ attrs?.onChange?.({ row });
+ }
+ } finally {
+ row[loadingKey] = false;
+ }
+ }
+ return h(Switch, finallyProps);
+ },
+ });
+
+ // 注册表格的操作按钮渲染器
+ vxeUI.renderer.add('CellOperation', {
+ renderTableDefault({ attrs, options, props }, { column, row }) {
+ const defaultProps = { size: 'small', type: 'link', ...props };
+ let align = 'end';
+ if (column.align === 'center') {
+ align = 'center';
+ } else if (column.align === 'left') {
+ align = 'start';
+ }
+ const presets: Recordable> = {
+ delete: {
+ danger: true,
+ text: $t('common.delete'),
+ },
+ edit: {
+ text: $t('common.edit'),
+ },
+ };
+ const operations: Array> = (
+ options || ['edit', 'delete']
+ )
+ .map((opt) => {
+ if (isString(opt)) {
+ return presets[opt]
+ ? { code: opt, ...presets[opt], ...defaultProps }
+ : {
+ code: opt,
+ text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
+ ...defaultProps,
+ };
+ } else {
+ return { ...defaultProps, ...presets[opt.code], ...opt };
+ }
+ })
+ .map((opt) => {
+ const optBtn: Recordable = {};
+ Object.keys(opt).forEach((key) => {
+ optBtn[key] = isFunction(opt[key]) ? opt[key](row) : opt[key];
+ });
+ return optBtn;
+ })
+ .filter((opt) => opt.show !== false);
+
+ function renderBtn(opt: Recordable, listen = true) {
+ return h(
+ Button,
+ {
+ ...props,
+ ...opt,
+ icon: undefined,
+ onClick: listen
+ ? () =>
+ attrs?.onClick?.({
+ code: opt.code,
+ row,
+ })
+ : undefined,
+ },
+ {
+ default: () => {
+ const content = [];
+ if (opt.icon) {
+ content.push(
+ h(IconifyIcon, { class: 'size-5', icon: opt.icon }),
+ );
+ }
+ content.push(opt.text);
+ return content;
+ },
+ },
+ );
+ }
+
+ function renderConfirm(opt: Recordable) {
+ return h(
+ Popconfirm,
+ {
+ getPopupContainer(el) {
+ if (el.closest('.fixed-right--wrapper')) {
+ return document.body;
+ }
+ return (
+ el
+ .closest('.vxe-table--viewport-wrapper')
+ ?.querySelector('.vxe-table--main-wrapper')
+ ?.querySelector('tbody') || document.body
+ );
+ },
+ placement: 'topLeft',
+ title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
+ ...props,
+ ...opt,
+ icon: undefined,
+ onConfirm: () => {
+ attrs?.onClick?.({
+ code: opt.code,
+ row,
+ });
+ },
+ },
+ {
+ default: () => renderBtn({ ...opt }, false),
+ description: () =>
+ h(
+ 'div',
+ { class: 'truncate' },
+ $t('ui.actionMessage.deleteConfirm', [
+ row[attrs?.nameField || 'name'],
+ ]),
+ ),
+ },
+ );
+ }
+
+ function renderDropdown(opt: Recordable) {
+ const menuItems =
+ opt.items?.map((item: Recordable) => ({
+ key: item.code || item.text,
+ label: item.text,
+ icon: item.icon ?? undefined,
+ disabled: item.disabled ?? undefined,
+ })) || [];
+
+ return h(
+ Dropdown,
+ {
+ getPopupContainer(el) {
+ if (el.closest('.fixed-right--wrapper')) {
+ return document.body;
+ }
+ return (
+ el
+ .closest('.vxe-table--viewport-wrapper')
+ ?.querySelector('.vxe-table--main-wrapper')
+ ?.querySelector('tbody') || document.body
+ );
+ },
+ placement: 'bottomLeft',
+ ...props,
+ ...opt,
+ menu: {
+ items: menuItems,
+ onClick: ({ key }: { key: string }) =>
+ attrs?.onClick?.({ code: key, row }),
+ },
+ },
+ {
+ default: () => renderBtn({ ...opt, icon: 'tabler:dots' }, false),
+ },
+ );
+ }
+
+ const btns = operations.map((opt) => {
+ if (opt.code === 'delete') {
+ return renderConfirm(opt);
+ } else if (opt.code === 'more') {
+ return renderDropdown(opt);
+ } else {
+ return renderBtn(opt);
+ }
+ });
+ return h(
+ 'div',
+ {
+ class: 'flex table-operations',
+ style: { justifyContent: align },
+ },
+ btns,
+ );
+ },
+ });
+
+ // 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
+ // vxeUI.formats.add
+ },
+ useVbenForm,
+});
+
+export const useVbenVxeGrid = >(
+ ...rest: Parameters>
+) => useGrid(...rest);
+
+export type OnActionClickParams> = {
+ code: string;
+ row: T;
+};
+
+export type OnActionClickFn> = (
+ params: OnActionClickParams,
+) => void;
+
+export type * from '@vben/plugins/vxe-table';
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/auth.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/auth.ts
new file mode 100644
index 0000000..41607af
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/auth.ts
@@ -0,0 +1,63 @@
+import { miniRequestClient, requestClient } from '#/api/request';
+
+export interface CaptchaResult {
+ is_enabled: boolean;
+ expire_seconds: number;
+ uuid: string;
+ image: string;
+}
+
+export interface LoginParams {
+ username: string;
+ password: string;
+ uuid: string;
+ captcha: string;
+}
+
+export interface LoginResult {
+ access_token: string;
+ session_uuid: string;
+}
+
+export type RefreshTokenResult = LoginResult;
+
+/**
+ * 登录验证码
+ */
+export async function getCaptchaApi() {
+ return requestClient.get('/api/v1/auth/captcha');
+}
+
+/**
+ * 登录
+ */
+export async function loginApi(data: LoginParams) {
+ return requestClient.post('/api/v1/auth/login', data);
+}
+
+/**
+ * 刷新accessToken
+ */
+export async function refreshTokenApi() {
+ return miniRequestClient.post(
+ '/api/v1/auth/refresh',
+ undefined,
+ {
+ withCredentials: true,
+ },
+ );
+}
+
+/**
+ * 退出登录
+ */
+export async function logoutApi() {
+ return requestClient.post('/api/v1/auth/logout');
+}
+
+/**
+ * 获取用户权限码
+ */
+export async function getAccessCodesApi() {
+ return requestClient.get('/api/v1/auth/codes');
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/index.ts
new file mode 100644
index 0000000..0425686
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/index.ts
@@ -0,0 +1,4 @@
+export * from './auth';
+export * from './menu';
+export * from './upload';
+export * from './user';
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/menu.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/menu.ts
new file mode 100644
index 0000000..8aadf82
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/menu.ts
@@ -0,0 +1,125 @@
+import type { RouteRecordStringComponent } from '@vben/types';
+
+import { $t } from '@vben/locales';
+
+import { requestClient } from '#/api/request';
+
+export interface SysMenuResult {
+ id: number;
+ title: string;
+ name: string;
+ path: string;
+ sort: number;
+ icon?: string;
+ type: number;
+ component?: string;
+ perms?: string;
+ status: number;
+ display: number;
+ cache: number;
+ remark?: string;
+ parent_id?: number;
+ created_time: string;
+}
+
+export interface SysMenuTreeResult extends SysMenuResult {
+ children?: SysMenuTreeResult[];
+}
+
+export interface SysMenuParams {
+ title: string;
+ name: string;
+ path?: string;
+ parent_id?: number;
+ sort?: number;
+ icon?: string;
+ type?: number;
+ component?: string;
+ perms?: string;
+ status?: number;
+ display?: number;
+ cache?: number;
+ link?: string;
+ remark?: string;
+}
+
+export interface SysMenuTreeParams {
+ title?: string;
+ status: number;
+}
+
+/**
+ * 获取用户所有菜单
+ */
+export async function getAllMenusApi() {
+ return requestClient.get(
+ '/api/v1/sys/menus/sidebar',
+ );
+}
+
+export async function getSysMenuTreeApi(params: SysMenuTreeParams) {
+ const transformMenuTitles = (menuData: SysMenuTreeResult[]) => {
+ return menuData.map((item) => {
+ const transformedItem = {
+ ...item,
+ title: $t(item.title),
+ };
+
+ if (item.children && item.children.length > 0) {
+ transformedItem.children = transformMenuTitles(item.children);
+ }
+
+ return transformedItem;
+ });
+ };
+
+ const filterMenuTree = (
+ menuData: SysMenuTreeResult[],
+ keyword: string,
+ ): SysMenuTreeResult[] => {
+ const lowerKeyword = keyword.toLowerCase();
+ const result: SysMenuTreeResult[] = [];
+ for (const item of menuData) {
+ const filteredChildren = item.children?.length
+ ? filterMenuTree(item.children, keyword)
+ : [];
+ const isMatch = item.title.toLowerCase().includes(lowerKeyword);
+ if (isMatch || filteredChildren.length > 0) {
+ result.push({
+ ...item,
+ children: isMatch ? item.children : filteredChildren,
+ });
+ }
+ }
+ return result;
+ };
+
+ const { title, ...restParams } = params;
+
+ const data = await requestClient.get(
+ '/api/v1/sys/menus',
+ {
+ params: restParams,
+ },
+ );
+
+ const translatedData = transformMenuTitles(data);
+
+ if (title) {
+ return filterMenuTree(translatedData, title);
+ }
+
+ return translatedData;
+}
+
+export async function createSysMenuApi(data: SysMenuParams) {
+ return requestClient.post('/api/v1/sys/menus', data);
+}
+
+export async function updateSysMenuApi(pk: number, data: SysMenuParams) {
+ return requestClient.put(`/api/v1/sys/menus/${pk}`, data);
+}
+
+export async function deleteSysMenuApi(pk: number) {
+ return requestClient.delete(`/api/v1/sys/menus/${pk}`);
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/upload.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/upload.ts
new file mode 100644
index 0000000..ebbf958
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/upload.ts
@@ -0,0 +1,27 @@
+import { requestClient } from '#/api/request';
+
+interface UploadFileParams {
+ file: File;
+ onError?: (error: Error) => void;
+ onProgress?: (progress: { percent: number }) => void;
+ onSuccess?: (data: any, file: File) => void;
+}
+export async function upload_file({
+ file,
+ onError,
+ onProgress,
+ onSuccess,
+}: UploadFileParams) {
+ try {
+ onProgress?.({ percent: 0 });
+
+ const data = await requestClient.upload('/api/v1/sys/files/upload', {
+ file,
+ });
+
+ onProgress?.({ percent: 100 });
+ onSuccess?.(data, file);
+ } catch (error) {
+ onError?.(error instanceof Error ? error : new Error(String(error)));
+ }
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/user.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/user.ts
new file mode 100644
index 0000000..d759e56
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/core/user.ts
@@ -0,0 +1,142 @@
+import type { UserInfo } from '@vben/types';
+
+import type { SysDeptResult, SysRoleResult } from '#/api';
+
+import { requestClient } from '#/api/request';
+
+export interface MyUserInfo extends UserInfo {
+ id: number;
+ nickname: string;
+ email?: string;
+ phone?: string;
+ dept?: string;
+ last_login_time: string;
+}
+
+export interface SysUserResult {
+ id: number;
+ uuid: string;
+ dept_id?: number;
+ username: string;
+ nickname: string;
+ email?: string;
+ phone?: string;
+ avatar?: string;
+ status: number;
+ is_superuser: boolean;
+ is_staff: boolean;
+ is_multi_login: boolean;
+ join_time: string;
+ last_login_time: string;
+ dept?: SysDeptResult;
+ roles: SysRoleResult[];
+}
+
+export interface SysUserParams {
+ dept?: number;
+ username?: string;
+ phone?: string;
+ status?: number;
+ page?: number;
+ size?: number;
+}
+
+export interface SysUpdateUserParams {
+ dept_id?: number;
+ username: string;
+ nickname: string;
+ avatar?: string;
+ email?: string;
+ phone?: string;
+ roles: number[];
+}
+
+export interface SysAddUserParams extends SysUpdateUserParams {
+ password: string;
+}
+
+export interface SysUpdatePasswordParams {
+ old_password: string;
+ new_password: string;
+ confirm_password: string;
+}
+
+export interface SysUpdateUserPhoneParams {
+ phone: string;
+ captcha: string;
+}
+
+export interface SysUpdateUserEmailParams {
+ email: string;
+ captcha: string;
+}
+
+export interface SysUpdateUserNicknameParams {
+ nickname: string;
+}
+
+export interface SysUpdateUserAvatarParams {
+ avatar: string;
+}
+
+export interface SysResetPasswordParams {
+ password: string;
+}
+
+/**
+ * 获取用户信息
+ */
+export async function getUserInfoApi() {
+ return requestClient.get('/api/v1/sys/users/me');
+}
+
+export async function getSysUserListApi(params: SysUserParams) {
+ return requestClient.get('/api/v1/sys/users', { params });
+}
+
+export async function createSysUserApi(data: SysAddUserParams) {
+ return requestClient.post('/api/v1/sys/users', data);
+}
+
+export async function updateSysUserApi(pk: number, data: SysUpdateUserParams) {
+ return requestClient.put(`/api/v1/sys/users/${pk}`, data);
+}
+
+export async function updateSysUserPermissionApi(pk: number, type: string) {
+ return requestClient.put(`/api/v1/sys/users/${pk}/permissions`, undefined, {
+ params: { type },
+ paramsSerializer: 'repeat',
+ });
+}
+
+export async function updateSysUserAvatarApi(data: SysUpdateUserAvatarParams) {
+ return requestClient.put(`/api/v1/sys/users/me/avatar`, data);
+}
+
+export async function updateSysUserNicknameApi(
+ data: SysUpdateUserNicknameParams,
+) {
+ return requestClient.put(`/api/v1/sys/users/me/nickname`, data);
+}
+export async function updateSysUserPhoneApi(data: SysUpdateUserPhoneParams) {
+ return requestClient.put(`/api/v1/sys/users/me/phone`, data);
+}
+
+export async function updateSysUserEmailApi(data: SysUpdateUserEmailParams) {
+ return requestClient.put(`/api/v1/sys/users/me/email`, data);
+}
+
+export async function updateSysUserPasswordApi(data: SysUpdatePasswordParams) {
+ return requestClient.put(`/api/v1/sys/users/me/password`, data);
+}
+
+export async function resetSysUserPasswordApi(
+ pk: number,
+ data: SysResetPasswordParams,
+) {
+ return requestClient.put(`/api/v1/sys/users/${pk}/password`, data);
+}
+
+export async function deleteSysUserApi(pk: number) {
+ return requestClient.delete(`/api/v1/sys/users/${pk}`);
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/data-permission.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/data-permission.ts
new file mode 100644
index 0000000..bf952d1
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/data-permission.ts
@@ -0,0 +1,136 @@
+import { requestClient } from '#/api/request';
+
+export interface SysDataScopeResult {
+ id: number;
+ name: string;
+ status: number;
+ created_time: string;
+ updated_time: string;
+}
+
+export interface SysDataRuleResult {
+ id: number;
+ name: string;
+ model: string;
+ column: string;
+ operator: string;
+ expression: string;
+ value: string;
+ created_time: string;
+ updated_time: string;
+}
+
+export interface SysDataScopeParams {
+ name?: string;
+ status?: number;
+ page?: number;
+ size?: number;
+}
+
+export interface CreateSysDataScopeParams {
+ name: string;
+ status: number;
+}
+
+export interface SysDataScopeRulesResult extends SysDataScopeResult {
+ rules: SysDataRuleResult[];
+}
+
+export interface SysDataRuleParams {
+ name?: string;
+}
+
+export interface SysDataRuleModelColumnsResult {
+ key: string;
+ comment: string;
+}
+
+export interface SysDataRuleTemplateVariableResult {
+ key: string;
+ comment: string;
+}
+
+export interface CreateSysDataRuleParams {
+ name: string;
+ model: string;
+ column: string;
+ operator: string;
+ expression: string;
+ value: string;
+}
+
+export async function getSysDataScopeListApi(params: SysDataScopeParams) {
+ return requestClient.get('/api/v1/sys/data-scopes', {
+ params,
+ });
+}
+
+export async function getSysDataScopesApi() {
+ return requestClient.get('/api/v1/sys/data-scopes/all');
+}
+
+export async function getSysDataScopeRulesApi(pk: number) {
+ return requestClient.get(
+ `/api/v1/sys/data-scopes/${pk}/rules`,
+ );
+}
+
+export async function createSysDataScope(data: CreateSysDataScopeParams) {
+ return requestClient.post('/api/v1/sys/data-scopes', data);
+}
+
+export async function updateSysDataScope(
+ pk: number,
+ data: CreateSysDataScopeParams,
+) {
+ return requestClient.put(`/api/v1/sys/data-scopes/${pk}`, data);
+}
+
+export async function updateSysDataScopeRulesApi(pk: number, rules: number[]) {
+ return requestClient.put(`/api/v1/sys/data-scopes/${pk}/rules`, { rules });
+}
+
+export async function deleteSysDataScopeApi(pks: number[]) {
+ return requestClient.delete(`/api/v1/sys/data-scopes`, { data: { pks } });
+}
+
+export async function getSysDataRuleListApi(params: SysDataRuleParams) {
+ return requestClient.get('/api/v1/sys/data-rules', {
+ params,
+ });
+}
+
+export async function getSysDataRulesApi() {
+ return requestClient.get('/api/v1/sys/data-rules/all');
+}
+
+export async function getSysDataRuleModelsApi() {
+ return requestClient.get('/api/v1/sys/data-rules/models');
+}
+
+export async function getSysDataRuleModelColumnsApi(model: string) {
+ return requestClient.get(
+ `/api/v1/sys/data-rules/models/${model}/columns`,
+ );
+}
+
+export async function getSysDataRuleTemplateVariablesApi() {
+ return requestClient.get(
+ '/api/v1/sys/data-rules/value-template-variables',
+ );
+}
+
+export async function createSysDataRuleApi(data: CreateSysDataRuleParams) {
+ return requestClient.post('/api/v1/sys/data-rules', data);
+}
+
+export async function updateSysDataRuleApi(
+ pk: number,
+ data: CreateSysDataRuleParams,
+) {
+ return requestClient.put(`/api/v1/sys/data-rules/${pk}`, data);
+}
+
+export async function deleteSysDataRuleApi(pks: number[]) {
+ return requestClient.delete(`/api/v1/sys/data-rules`, { data: { pks } });
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/dept.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/dept.ts
new file mode 100644
index 0000000..eea311b
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/dept.ts
@@ -0,0 +1,71 @@
+import { requestClient } from './request';
+
+export interface SysDeptResult {
+ id: number;
+ name: string;
+ parent_id: number;
+ sort: number;
+ leader?: string;
+ phone?: string;
+ email?: string;
+ status: number;
+ created_time: string;
+}
+
+export interface SysDeptTreeResult extends SysDeptResult {
+ children?: SysDeptTreeResult[];
+}
+
+export interface SysDeptParams {
+ name: string;
+ parent_id?: number;
+ sort?: number;
+ leader?: string;
+ phone?: string;
+ email?: string;
+ status: number;
+}
+
+export interface SysDeptTreeParams {
+ name?: string;
+ leader?: string;
+ phone?: string;
+ status?: number;
+}
+
+/**
+ * 获取部门树
+ */
+export async function getSysDeptTreeApi(params: SysDeptTreeParams) {
+ return requestClient.get('/api/v1/sys/depts', {
+ params,
+ });
+}
+
+/**
+ * 获取部门详情
+ */
+export async function getSysDeptDetailApi(pk: number) {
+ return requestClient.get(`/api/v1/sys/depts/${pk}`);
+}
+
+/**
+ * 创建部门
+ */
+export async function createSysDeptApi(data: SysDeptParams) {
+ return requestClient.post('/api/v1/sys/depts', data);
+}
+
+/**
+ * 更新部门
+ */
+export async function updateSysDeptApi(pk: number, data: SysDeptParams) {
+ return requestClient.put(`/api/v1/sys/depts/${pk}`, data);
+}
+
+/**
+ * 删除部门
+ */
+export async function deleteSysDeptApi(pk: number) {
+ return requestClient.delete(`/api/v1/sys/depts/${pk}`);
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/index.ts
new file mode 100644
index 0000000..d5f26f0
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/index.ts
@@ -0,0 +1,8 @@
+export * from './core';
+export * from './data-permission';
+export * from './dept';
+export * from './log';
+export * from './monitor';
+export * from './plugin';
+export * from './role';
+export * from './scheduler';
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/log.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/log.ts
new file mode 100644
index 0000000..4897777
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/log.ts
@@ -0,0 +1,72 @@
+import type { PaginationResult } from '#/types';
+
+import { requestClient } from './request';
+
+export interface LoginLogParams {
+ username?: string;
+ status?: number;
+ ip?: string;
+ page?: number;
+ size?: number;
+}
+
+export interface LoginLogResult {
+ id: number;
+ username: string;
+ status: number;
+ ip: string;
+ country?: string;
+ region?: string;
+ os?: string;
+ browser?: string;
+ device?: string;
+ msg: string;
+ login_time: string;
+}
+
+export type OperaLogParams = LoginLogParams;
+
+export interface OperaLogResult {
+ id: number;
+ trace_id: string;
+ username?: string;
+ method: string;
+ title: string;
+ path: string;
+ ip: string;
+ country?: string;
+ region?: string;
+ city?: string;
+ user_agent: string;
+ os?: string;
+ browser?: string;
+ device?: string;
+ args?: JSON;
+ status: number;
+ code: string;
+ msg: string;
+ cost_time: number;
+ opera_time: string;
+}
+
+export async function getLoginLogListApi(params: LoginLogParams) {
+ return requestClient.get>(
+ '/api/v1/logs/login',
+ { params },
+ );
+}
+
+export async function deleteLoginLogApi(pks: number[]) {
+ return requestClient.delete('/api/v1/logs/login', { data: { pks } });
+}
+
+export async function getOperaLogListApi(params: OperaLogParams) {
+ return requestClient.get>(
+ '/api/v1/logs/opera',
+ { params },
+ );
+}
+
+export async function deleteOperaLogApi(pks: number[]) {
+ return requestClient.delete('/api/v1/logs/opera', { data: { pks } });
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/monitor.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/monitor.ts
new file mode 100644
index 0000000..56f5115
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/monitor.ts
@@ -0,0 +1,57 @@
+import { requestClient } from './request';
+
+export interface ServerMonitorResult {
+ cpu: Record;
+ mem: Record;
+ sys: Record;
+ disk: Record[];
+ service: Record;
+}
+
+export interface RedisMonitorResult {
+ info: Record;
+ stats: Record[];
+}
+
+export interface OnlineMonitorResult {
+ id: number;
+ session_uuid: string;
+ username: string;
+ nickname: string;
+ ip: string;
+ os: string;
+ browser: string;
+ device: string;
+ status: number;
+ last_login_time: string;
+ expires_time: number;
+}
+
+export interface MonitorOnlineParams {
+ username: string;
+}
+
+export interface KickOutOnlineParams {
+ session_uuid: string;
+}
+
+export async function getServerMonitorApi() {
+ return requestClient.get('/api/v1/monitors/server');
+}
+
+export async function getRedisMonitorApi() {
+ return requestClient.get('/api/v1/monitors/redis');
+}
+
+export async function getOnlineMonitorApi(params: MonitorOnlineParams) {
+ return requestClient.get('/api/v1/monitors/sessions', {
+ params,
+ });
+}
+
+export async function kickOutOnlineApi(
+ pk: number,
+ params: KickOutOnlineParams,
+) {
+ return requestClient.delete(`/api/v1/monitors/sessions/${pk}`, { params });
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/plugin.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/plugin.ts
new file mode 100644
index 0000000..5198d8f
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/plugin.ts
@@ -0,0 +1,39 @@
+import { requestClient } from '#/api/request';
+
+export interface PluginResult {
+ [key: string]: any;
+}
+
+export async function getPluginListApi() {
+ return requestClient.get('/api/v1/sys/plugins');
+}
+
+export async function getPluginChangedApi() {
+ return requestClient.get('/api/v1/sys/plugins/changed');
+}
+
+export async function installZipPluginApi(file: File) {
+ return await requestClient.upload(
+ '/api/v1/sys/plugins',
+ { file },
+ { params: { type: 'zip' }, timeout: 60_000 },
+ );
+}
+
+export async function installGitPluginApi(repo_url: string) {
+ return await requestClient.post('/api/v1/sys/plugins', undefined, {
+ params: { type: 'git', repo_url },
+ });
+}
+
+export async function updatePluginStatus(plugin: string) {
+ return await requestClient.put(`/api/v1/sys/plugins/${plugin}/status`);
+}
+
+export async function downloadPluginApi(plugin: string) {
+ return await requestClient.download(`/api/v1/sys/plugins/${plugin}`);
+}
+
+export async function uninstallPluginApi(plugin: string) {
+ return await requestClient.delete(`/api/v1/sys/plugins/${plugin}`);
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/request.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/request.ts
new file mode 100644
index 0000000..17fc3c5
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/request.ts
@@ -0,0 +1,151 @@
+/**
+ * 该文件可自行根据业务逻辑进行调整
+ */
+import type { RequestClientOptions } from '@vben/request';
+
+import { useAppConfig } from '@vben/hooks';
+import { preferences } from '@vben/preferences';
+import {
+ authenticateResponseInterceptor,
+ defaultResponseInterceptor,
+ errorMessageResponseInterceptor,
+ RequestClient,
+} from '@vben/request';
+import { useAccessStore } from '@vben/stores';
+
+import { message } from 'antdv-next';
+
+import { useAuthStore } from '#/store';
+
+import { refreshTokenApi } from './core';
+
+const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
+
+function createRequestClient(baseURL: string, options?: RequestClientOptions) {
+ const client = new RequestClient({
+ ...options,
+ baseURL,
+ });
+
+ /**
+ * 重新认证逻辑
+ */
+ async function doReAuthenticate() {
+ console.warn('Access token or refresh token is invalid or expired. ');
+ const accessStore = useAccessStore();
+ const authStore = useAuthStore();
+ accessStore.setAccessToken(null);
+ accessStore.setAccessSessionUuid(null);
+ if (
+ preferences.app.loginExpiredMode === 'modal' &&
+ accessStore.isAccessChecked
+ ) {
+ accessStore.setLoginExpired(true);
+ } else {
+ await authStore.logout();
+ }
+ }
+
+ /**
+ * 刷新token逻辑
+ */
+ async function doRefreshToken() {
+ const accessStore = useAccessStore();
+ const resp = await refreshTokenApi();
+ const newToken = resp.access_token;
+ accessStore.setAccessToken(newToken);
+ accessStore.setAccessSessionUuid(resp.session_uuid);
+ return newToken;
+ }
+
+ function formatToken(token: null | string) {
+ return token ? `Bearer ${token}` : null;
+ }
+
+ // 请求头处理
+ client.addRequestInterceptor({
+ fulfilled: async (config) => {
+ const accessStore = useAccessStore();
+
+ config.headers.Authorization = formatToken(accessStore.accessToken);
+ config.headers['Accept-Language'] = preferences.app.locale;
+ return config;
+ },
+ });
+
+ // 处理返回的响应数据格式
+ client.addResponseInterceptor(
+ defaultResponseInterceptor({
+ codeField: 'code',
+ dataField: 'data',
+ successCode: 200,
+ }),
+ );
+
+ // token过期的处理
+ client.addResponseInterceptor(
+ authenticateResponseInterceptor({
+ client,
+ doReAuthenticate,
+ doRefreshToken,
+ enableRefreshToken: preferences.app.enableRefreshToken,
+ formatToken,
+ }),
+ );
+
+ // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
+ client.addResponseInterceptor(
+ errorMessageResponseInterceptor((msg: string, error) => {
+ // 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
+ // 当前mock接口返回的错误字段是 error 或者 message
+ const responseData = error?.response?.data ?? {};
+ const errorMessage =
+ responseData?.error ?? responseData?.msg ?? error?.msg ?? '';
+ // 如果没有错误信息,则会根据状态码进行提示
+ message.error(errorMessage || msg);
+ }),
+ );
+
+ return client;
+}
+
+function createMiniRequestClient(
+ baseURL: string,
+ options?: RequestClientOptions,
+) {
+ const client = new RequestClient({
+ ...options,
+ baseURL,
+ });
+
+ // 处理返回的响应数据格式
+ client.addResponseInterceptor(
+ defaultResponseInterceptor({
+ codeField: 'code',
+ dataField: 'data',
+ successCode: 200,
+ }),
+ );
+
+ // 通用的错误处理
+ client.addResponseInterceptor(
+ errorMessageResponseInterceptor((msg: string, error) => {
+ const responseData = error?.response?.data ?? {};
+ const errorMessage =
+ responseData?.error ?? responseData?.msg ?? error?.msg ?? '';
+ message.error(errorMessage || msg);
+ }),
+ );
+
+ return client;
+}
+
+export const requestClient = createRequestClient(apiURL, {
+ responseReturn: 'data',
+});
+
+export const miniRequestClient = createMiniRequestClient(apiURL, {
+ responseReturn: 'data',
+});
+
+export const baseRequestClient = new RequestClient({ baseURL: apiURL });
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/role.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/role.ts
new file mode 100644
index 0000000..10338d4
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/role.ts
@@ -0,0 +1,67 @@
+import type { RouteRecordStringComponent } from '@vben/types';
+
+import { requestClient } from './request';
+
+export interface SysRoleParams {
+ name?: string;
+ status?: number;
+ page?: number;
+ size?: number;
+}
+
+export interface SysRoleResult {
+ id: number;
+ name: string;
+ status: number;
+ is_filter_scopes: boolean;
+ remark?: string;
+ created_time: string;
+ updated_time: string;
+}
+
+export interface CreateSysRoleParams {
+ name: string;
+ status: number;
+ remark?: string;
+}
+
+/**
+ * 获取系统角色列表
+ */
+export async function getSysRoleListApi(params: SysRoleParams) {
+ return requestClient.get('/api/v1/sys/roles', { params });
+}
+
+export async function getAllSysRoleApi() {
+ return requestClient.get('/api/v1/sys/roles/all');
+}
+
+export async function getSysRoleMenuApi(pk: number) {
+ return requestClient.get(
+ `/api/v1/sys/roles/${pk}/menus`,
+ );
+}
+
+export async function getSysRoleDataScopesApi(pk: number) {
+ return requestClient.get(`/api/v1/sys/roles/${pk}/scopes`);
+}
+
+export async function createSysRoleApi(data: CreateSysRoleParams) {
+ return requestClient.post('/api/v1/sys/roles', data);
+}
+
+export async function updateSysRoleApi(pk: number, data: CreateSysRoleParams) {
+ return requestClient.put(`/api/v1/sys/roles/${pk}`, data);
+}
+
+export async function updateSysRoleMenuApi(pk: number, menus: number[]) {
+ return requestClient.put(`/api/v1/sys/roles/${pk}/menus`, { menus });
+}
+
+export async function updateSysRoleDataScopesApi(pk: number, scopes: number[]) {
+ return requestClient.put(`/api/v1/sys/roles/${pk}/scopes`, { scopes });
+}
+
+export async function deleteSysRoleApi(pks: number[]) {
+ return requestClient.delete(`/api/v1/sys/roles`, { data: { pks } });
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/scheduler.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/scheduler.ts
new file mode 100644
index 0000000..0d6a58f
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/api/scheduler.ts
@@ -0,0 +1,125 @@
+import type { Dayjs } from 'dayjs';
+
+import type { PaginationResult } from '#/types';
+
+import { requestClient } from '#/api/request';
+
+export interface TaskResultParams {
+ name?: string;
+ task_id?: string;
+ page?: number;
+ size?: number;
+}
+
+export interface TaskSchedulerParams {
+ name?: string;
+ type?: number;
+ page?: number;
+ size?: number;
+}
+
+export interface TaskResult {
+ id: number;
+ task_id: string;
+ status: string;
+ result?: string;
+ date_done?: string;
+ traceback?: string;
+ name?: string;
+ args?: string;
+ kwargs?: string;
+ worker?: string;
+ retries?: number;
+ queue?: string;
+}
+
+export interface CreateTaskSchedulerParams {
+ name: string;
+ task: string;
+ args?: string;
+ kwargs?: string;
+ queue?: string;
+ exchange?: string;
+ routing_key?: string;
+ start_time?: Dayjs;
+ expire_time?: Dayjs;
+ expire_seconds?: number;
+ type: number;
+ interval_every?: number;
+ interval_period?: string;
+ crontab: string;
+ one_off: boolean;
+ remark?: string;
+}
+
+export interface TaskSchedulerResult extends CreateTaskSchedulerParams {
+ id: number;
+ enabled: boolean;
+ total_run_count: number;
+ last_run_time: string;
+ created_time: string;
+ updated_time?: string;
+}
+
+export async function getTaskResultApi(pk: number) {
+ return requestClient.get(`/api/v1/task-results/${pk}`);
+}
+
+export async function getTaskResultListApi(params?: TaskResultParams) {
+ return requestClient.get>(
+ '/api/v1/task-results',
+ {
+ params,
+ },
+ );
+}
+
+export async function deleteTaskResultApi(pks: number[]) {
+ return requestClient.delete('/api/v1/task-results', { data: { pks } });
+}
+
+export async function getAllTaskSchedulerApi() {
+ return requestClient.get('/api/v1/schedulers/all');
+}
+
+export async function getTaskSchedulerListApi(params?: TaskSchedulerParams) {
+ return requestClient.get>(
+ '/api/v1/schedulers',
+ { params },
+ );
+}
+
+export async function getTaskSchedulerApi(pk: number) {
+ return requestClient.get(`/api/v1/schedulers/${pk}`);
+}
+
+export async function createTaskSchedulerApi(data: CreateTaskSchedulerParams) {
+ return requestClient.post('/api/v1/schedulers', data);
+}
+
+export async function updateTaskSchedulerApi(
+ pk: number,
+ data: CreateTaskSchedulerParams,
+) {
+ return requestClient.put(`/api/v1/schedulers/${pk}`, data);
+}
+
+export async function updateTaskSchedulerStatusApi(pk: number) {
+ return requestClient.put(`/api/v1/schedulers/${pk}/status`);
+}
+
+export async function deleteTaskSchedulerApi(pk: number) {
+ return requestClient.delete(`/api/v1/schedulers/${pk}`);
+}
+
+export async function executeTaskSchedulerApi(pk: number) {
+ return requestClient.post(`/api/v1/schedulers/${pk}/execute`);
+}
+
+export async function getTaskRegisteredApi() {
+ return requestClient.get('/api/v1/tasks/registered');
+}
+
+export async function revokeTaskSchedulerApi(task_id: string) {
+ return requestClient.delete(`/api/v1/tasks/${task_id}/cancel`);
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/app.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/app.vue
new file mode 100644
index 0000000..518f506
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/app.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/bootstrap.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/bootstrap.ts
new file mode 100644
index 0000000..6d72f26
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/bootstrap.ts
@@ -0,0 +1,80 @@
+import { createApp, watchEffect } from 'vue';
+
+import { registerAccessDirective } from '@vben/access';
+import { registerLoadingDirective } from '@vben/common-ui/es/loading';
+import { preferences } from '@vben/preferences';
+import { initStores } from '@vben/stores';
+import '@vben/styles';
+import '@vben/styles/antdv-next';
+
+import { useTitle } from '@vueuse/core';
+import Antd from 'antdv-next';
+
+import { $t, setupI18n } from '#/locales';
+
+import { initComponentAdapter } from './adapter/component';
+import { initSetupVbenForm } from './adapter/form';
+import App from './app.vue';
+import { router } from './router';
+
+async function bootstrap(namespace: string) {
+ // 初始化组件适配器
+ await initComponentAdapter();
+
+ // 初始化表单组件
+ await initSetupVbenForm();
+
+ // // 设置弹窗的默认配置
+ // setDefaultModalProps({
+ // fullscreenButton: false,
+ // });
+ // // 设置抽屉的默认配置
+ // setDefaultDrawerProps({
+ // zIndex: 1020,
+ // });
+
+ const app = createApp(App);
+
+ // 注册v-loading指令
+ registerLoadingDirective(app, {
+ loading: 'loading', // 在这里可以自定义指令名称,也可以明确提供false表示不注册这个指令
+ spinning: 'spinning',
+ });
+
+ // 国际化 i18n 配置
+ await setupI18n(app);
+
+ // 配置 pinia-tore
+ await initStores(app, { namespace });
+
+ // 安装权限指令
+ registerAccessDirective(app);
+
+ // 初始化 tippy
+ const { initTippy } = await import('@vben/common-ui/es/tippy');
+ initTippy(app);
+
+ // 全局加载 antdv
+ app.use(Antd);
+
+ // 配置路由及路由守卫
+ app.use(router);
+
+ // 配置Motion插件
+ const { MotionPlugin } = await import('@vben/plugins/motion');
+ app.use(MotionPlugin);
+
+ // 动态更新标题
+ watchEffect(() => {
+ if (preferences.app.dynamicTitle) {
+ const routeTitle = router.currentRoute.value.meta?.title;
+ const pageTitle =
+ (routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name;
+ useTitle(pageTitle);
+ }
+ });
+
+ app.mount('#app');
+}
+
+export { bootstrap };
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/layouts/auth.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/layouts/auth.vue
new file mode 100644
index 0000000..8ba66e8
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/layouts/auth.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/layouts/basic.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/layouts/basic.vue
new file mode 100644
index 0000000..c938f09
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/layouts/basic.vue
@@ -0,0 +1,256 @@
+
+
+
+
+
+
+
+
+ item.id && markRead(item.id)"
+ @remove="(item) => item.id && remove(item.id)"
+ @make-all="handleMakeAll"
+ @on-click="handleClick"
+ @view-all="viewAll"
+ />
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/layouts/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/layouts/index.ts
new file mode 100644
index 0000000..a432078
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/layouts/index.ts
@@ -0,0 +1,6 @@
+const BasicLayout = () => import('./basic.vue');
+const AuthPageLayout = () => import('./auth.vue');
+
+const IFrameView = () => import('@vben/layouts').then((m) => m.IFrameView);
+
+export { AuthPageLayout, BasicLayout, IFrameView };
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/README.md b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/README.md
new file mode 100644
index 0000000..7b45103
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/README.md
@@ -0,0 +1,3 @@
+# locale
+
+每个app使用的国际化可能不同,这里用于扩展国际化的功能,例如扩展 dayjs、antd组件库的多语言切换,以及app本身的国际化文件。
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/index.ts
new file mode 100644
index 0000000..54fb83c
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/index.ts
@@ -0,0 +1,110 @@
+import type { Locale } from 'antdv-next/dist/locale/index';
+
+import type { App } from 'vue';
+
+import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales';
+
+import { ref } from 'vue';
+
+import {
+ $t,
+ setupI18n as coreSetup,
+ loadLocalesMapFromDir,
+} from '@vben/locales';
+import { preferences } from '@vben/preferences';
+
+import antdEnLocale from 'antdv-next/dist/locale/en_US';
+import antdDefaultLocale from 'antdv-next/dist/locale/zh_CN';
+import dayjs from 'dayjs';
+
+const antdLocale = ref(antdDefaultLocale);
+
+const modules = import.meta.glob('./langs/**/*.json');
+
+const pluginModules = import.meta.glob('../plugins/**/langs/**/*.json');
+
+const localesMap = loadLocalesMapFromDir(
+ /\.\/langs\/([^/]+)\/(.*)\.json$/,
+ modules,
+);
+
+const pluginLocalesMap = loadLocalesMapFromDir(
+ /\/plugins\/[^/]+\/langs\/([^/]+)\/([^/]+)\.json$/,
+ pluginModules,
+);
+/**
+ * 加载应用特有的语言包
+ * 这里也可以改造为从服务端获取翻译数据
+ * @param lang
+ */
+async function loadMessages(lang: SupportedLanguagesType) {
+ const [appLocaleMessages, pluginLocalMessages] = await Promise.all([
+ localesMap[lang]?.(),
+ pluginLocalesMap[lang]?.(),
+ loadThirdPartyMessage(lang),
+ ]);
+ return { ...appLocaleMessages?.default, ...pluginLocalMessages?.default };
+}
+
+/**
+ * 加载第三方组件库的语言包
+ * @param lang
+ */
+async function loadThirdPartyMessage(lang: SupportedLanguagesType) {
+ await Promise.all([loadAntdLocale(lang), loadDayjsLocale(lang)]);
+}
+
+/**
+ * 加载dayjs的语言包
+ * @param lang
+ */
+async function loadDayjsLocale(lang: SupportedLanguagesType) {
+ let locale;
+ switch (lang) {
+ case 'en-US': {
+ locale = await import('dayjs/locale/en');
+ break;
+ }
+ case 'zh-CN': {
+ locale = await import('dayjs/locale/zh-cn');
+ break;
+ }
+ // 默认使用英语
+ default: {
+ locale = await import('dayjs/locale/en');
+ }
+ }
+ if (locale) {
+ dayjs.locale(locale);
+ } else {
+ console.error(`Failed to load dayjs locale for ${lang}`);
+ }
+}
+
+/**
+ * 加载antd的语言包
+ * @param lang
+ */
+async function loadAntdLocale(lang: SupportedLanguagesType) {
+ switch (lang) {
+ case 'en-US': {
+ antdLocale.value = antdEnLocale;
+ break;
+ }
+ case 'zh-CN': {
+ antdLocale.value = antdDefaultLocale;
+ break;
+ }
+ }
+}
+
+async function setupI18n(app: App, options: LocaleSetupOptions = {}) {
+ await coreSetup(app, {
+ defaultLocale: preferences.app.locale,
+ loadMessages,
+ missingWarn: !import.meta.env.PROD,
+ ...options,
+ });
+}
+
+export { $t, antdLocale, setupI18n };
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/en-US/common.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/en-US/common.json
new file mode 100644
index 0000000..0684791
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/en-US/common.json
@@ -0,0 +1,14 @@
+{
+ "form": {
+ "query": "Query",
+ "select": "Please select",
+ "status": "Status"
+ },
+ "table": {
+ "created_time": "Created Time",
+ "id": "ID",
+ "mark": "Mark",
+ "operation": "Operation",
+ "updated_time": "Updated time"
+ }
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/en-US/demos.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/en-US/demos.json
new file mode 100644
index 0000000..551f22b
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/en-US/demos.json
@@ -0,0 +1,14 @@
+{
+ "title": "Demos",
+ "antd": "Antdv Next",
+ "vben": {
+ "title": "Project",
+ "about": "About",
+ "document": "Document",
+ "antdv": "Ant Design Vue Version",
+ "antdv-next": "Antdv Next Version",
+ "naive-ui": "Naive UI Version",
+ "element-plus": "Element Plus Version",
+ "tdesign": "TDesign Vue Version"
+ }
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/en-US/page.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/en-US/page.json
new file mode 100644
index 0000000..1538185
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/en-US/page.json
@@ -0,0 +1,114 @@
+{
+ "auth": {
+ "login": "Login",
+ "register": "Register",
+ "codeLogin": "Code Login",
+ "qrcodeLogin": "Qr Code Login",
+ "forgetPassword": "Forget Password",
+ "captchaPlaceholder": "Please enter the captcha",
+ "captchaRequired": "The captcha is required",
+ "profile": "Profile"
+ },
+ "dashboard": {
+ "title": "Dashboard",
+ "analytics": "Analytics",
+ "workspace": "Workspace"
+ },
+ "menu": {
+ "system": "System",
+ "scheduler": "Scheduler",
+ "schedulerManage": "Manage",
+ "schedulerRecord": "Record",
+ "log": "System Log",
+ "login": "Login",
+ "monitor": "System Monitor",
+ "opera": "Opera",
+ "online": "Online",
+ "redis": "Redis",
+ "server": "Server",
+ "sysDataPermission": "Data Permission",
+ "sysDataScope": "Data Scope",
+ "sysDataRule": "Data Rule",
+ "sysPlugin": "Plugin",
+ "sysDept": "Dept",
+ "sysMenu": "User",
+ "sysRole": "Role",
+ "sysUser": "User",
+ "profile": "Profile"
+ },
+ "monitor": {
+ "redis": {
+ "cards": {
+ "commands": {
+ "title": "Command Statistics"
+ },
+ "memory": {
+ "title": "Memory Status"
+ }
+ },
+ "info": {
+ "title": "Server Info",
+ "redis_version": "Version",
+ "redis_mode": "Mode",
+ "role": "Role",
+ "tcp_port": "TCP Port",
+ "uptime": "Uptime",
+ "connected_clients": "Connected Clients",
+ "blocked_clients": "Blocked Clients",
+ "used_memory_human": "Used Memory",
+ "used_memory_rss_human": "RSS Memory",
+ "maxmemory_human": "Max Memory",
+ "mem_fragmentation_ratio": "Fragmentation Ratio",
+ "total_commands_processed": "Commands Processed",
+ "instantaneous_ops_per_sec": "Ops/Sec",
+ "rejected_connections": "Rejected Connections",
+ "keys_num": "Keys"
+ }
+ },
+ "server": {
+ "cpu": {
+ "title": "CPU",
+ "current_freq": "Current Freq (MHz)",
+ "logical_num": "Logical Cores",
+ "physical_num": "Physical Cores",
+ "usage": "CPU Usage"
+ },
+ "disk": {
+ "title": "Disk",
+ "device": "Device",
+ "dir": "Mount Point",
+ "free": "Free",
+ "total": "Total",
+ "type": "Type",
+ "usage": "Usage",
+ "used": "Used"
+ },
+ "memory": {
+ "title": "Memory",
+ "free": "Free",
+ "total": "Total",
+ "usage": "Usage",
+ "used": "Used"
+ },
+ "service": {
+ "title": "Service Info",
+ "name": "Service Name",
+ "version": "Version",
+ "home": "Home Path",
+ "cpu_usage": "CPU Usage",
+ "mem_vms": "Virtual Memory",
+ "mem_rss": "Physical Memory",
+ "mem_free": "Free Memory",
+ "startup": "Startup Time",
+ "elapsed": "Elapsed Time"
+ },
+ "system": {
+ "title": "System Info",
+ "name": "Hostname",
+ "ip": "IP Address",
+ "os": "OS",
+ "arch": "Architecture"
+ }
+ }
+ }
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/zh-CN/common.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/zh-CN/common.json
new file mode 100644
index 0000000..e050205
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/zh-CN/common.json
@@ -0,0 +1,14 @@
+{
+ "form": {
+ "query": "查询",
+ "select": "请选择",
+ "status": "状态"
+ },
+ "table": {
+ "created_time": "创建时间",
+ "id": "序号",
+ "mark": "备注",
+ "operation": "操作",
+ "updated_time": "更新时间"
+ }
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/zh-CN/demos.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/zh-CN/demos.json
new file mode 100644
index 0000000..ef4e43f
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/zh-CN/demos.json
@@ -0,0 +1,14 @@
+{
+ "title": "演示",
+ "antd": "Antdv Next",
+ "vben": {
+ "title": "项目",
+ "about": "关于",
+ "document": "文档",
+ "antdv": "Ant Design Vue 版本",
+ "antdv-next": "Antdv Next 版本",
+ "naive-ui": "Naive UI 版本",
+ "element-plus": "Element Plus 版本",
+ "tdesign": "TDesign Vue 版本"
+ }
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/zh-CN/page.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/zh-CN/page.json
new file mode 100644
index 0000000..3e32e5d
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/locales/langs/zh-CN/page.json
@@ -0,0 +1,114 @@
+{
+ "auth": {
+ "login": "登录",
+ "register": "注册",
+ "codeLogin": "验证码登录",
+ "qrcodeLogin": "二维码登录",
+ "forgetPassword": "忘记密码",
+ "captchaPlaceholder": "请输入验证码",
+ "captchaRequired": "验证码是必须的",
+ "profile": "个人中心"
+ },
+ "dashboard": {
+ "title": "概览",
+ "analytics": "分析页",
+ "workspace": "工作台"
+ },
+ "menu": {
+ "system": "系统管理",
+ "scheduler": "任务调度",
+ "schedulerManage": "任务管理",
+ "schedulerRecord": "执行记录",
+ "log": "日志管理",
+ "login": "登录日志",
+ "monitor": "系统监控",
+ "opera": "操作日志",
+ "online": "在线用户",
+ "redis": "Redis",
+ "server": "服务器",
+ "sysDataPermission": "数据权限",
+ "sysDataScope": "数据范围",
+ "sysDataRule": "数据规则",
+ "sysPlugin": "插件管理",
+ "sysDept": "部门管理",
+ "sysMenu": "菜单管理",
+ "sysRole": "角色管理",
+ "sysUser": "用户管理",
+ "profile": "个人中心"
+ },
+ "monitor": {
+ "redis": {
+ "cards": {
+ "commands": {
+ "title": "命令统计"
+ },
+ "memory": {
+ "title": "内存使用"
+ }
+ },
+ "info": {
+ "title": "服务器信息",
+ "redis_version": "版本",
+ "redis_mode": "运行模式",
+ "role": "节点角色",
+ "tcp_port": "监听端口",
+ "uptime": "运行时长",
+ "connected_clients": "已连接客户端数",
+ "blocked_clients": "阻塞客户端数",
+ "used_memory_human": "已使用内存",
+ "used_memory_rss_human": "RSS 内存",
+ "maxmemory_human": "最大内存限制",
+ "mem_fragmentation_ratio": "内存碎片率",
+ "total_commands_processed": "命令处理总数",
+ "instantaneous_ops_per_sec": "每秒操作数",
+ "rejected_connections": "拒绝连接数",
+ "keys_num": "键总数"
+ }
+ },
+ "server": {
+ "cpu": {
+ "title": "CPU 信息",
+ "current_freq": "当前频率 (MHz)",
+ "logical_num": "逻辑核心数",
+ "physical_num": "物理核心数",
+ "usage": "CPU 使用率"
+ },
+ "disk": {
+ "title": "磁盘信息",
+ "device": "设备名称",
+ "dir": "挂载点",
+ "free": "可用",
+ "total": "总容量",
+ "type": "文件系统类型",
+ "usage": "使用率",
+ "used": "已使用"
+ },
+ "memory": {
+ "free": "可用",
+ "title": "内存信息",
+ "total": "总量",
+ "usage": "使用率",
+ "used": "已使用"
+ },
+ "service": {
+ "title": "服务信息",
+ "name": "服务名称",
+ "version": "版本",
+ "home": "安装路径",
+ "cpu_usage": "CPU 使用率",
+ "mem_vms": "虚拟内存",
+ "mem_rss": "物理内存",
+ "mem_free": "可用内存",
+ "startup": "启动时间",
+ "elapsed": "运行时长"
+ },
+ "system": {
+ "title": "系统信息",
+ "name": "主机名",
+ "ip": "IP 地址",
+ "os": "操作系统",
+ "arch": "系统架构"
+ }
+ }
+ }
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/main.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/main.ts
new file mode 100644
index 0000000..5d728a0
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/main.ts
@@ -0,0 +1,31 @@
+import { initPreferences } from '@vben/preferences';
+import { unmountGlobalLoading } from '@vben/utils';
+
+import { overridesPreferences } from './preferences';
+
+/**
+ * 应用初始化完成之后再进行页面加载渲染
+ */
+async function initApplication() {
+ // name用于指定项目唯一标识
+ // 用于区分不同项目的偏好设置以及存储数据的key前缀以及其他一些需要隔离的数据
+ const env = import.meta.env.PROD ? 'prod' : 'dev';
+ const appVersion = import.meta.env.VITE_APP_VERSION;
+ const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${appVersion}-${env}`;
+
+ // app偏好设置初始化
+ await initPreferences({
+ namespace,
+ overrides: overridesPreferences,
+ });
+
+ // 启动应用并挂载
+ // vue应用主要逻辑及视图
+ const { bootstrap } = await import('./bootstrap');
+ await bootstrap(namespace);
+
+ // 移除并销毁loading
+ unmountGlobalLoading();
+}
+
+initApplication();
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/aliyun_sms/api/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/aliyun_sms/api/index.ts
new file mode 100644
index 0000000..c43840e
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/aliyun_sms/api/index.ts
@@ -0,0 +1,12 @@
+import { requestClient } from '#/api/request';
+
+interface phoneCaptchaParams {
+ phone: string;
+}
+
+/**
+ * 发送短信验证码
+ */
+export async function getPhoneCaptchaApi(data: phoneCaptchaParams) {
+ return requestClient.post('/api/v1/phones/captcha', data);
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/aliyun_sms/package.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/aliyun_sms/package.json
new file mode 100644
index 0000000..9aaac54
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/aliyun_sms/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@fba-ui/aliyun-sms",
+ "version": "5.7.0",
+ "private": true,
+ "type": "module"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/api/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/api/index.ts
new file mode 100644
index 0000000..8b76bd4
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/api/index.ts
@@ -0,0 +1,158 @@
+import type { Recordable } from '@vben/types';
+
+import type { PaginationResult } from '#/types';
+
+import { requestClient } from '#/api/request';
+
+export interface QueryCodeGenBusinessParams {
+ table_name?: string;
+ page?: number;
+ size?: number;
+}
+
+export interface CodeGenBusinessParams {
+ app_name: string;
+ table_name: string;
+ doc_comment: string;
+ table_comment?: string;
+ class_name?: string;
+ schema_name?: string;
+ filename?: string;
+ datetime_mixin?: boolean;
+ api_version?: string;
+ tag?: string;
+ gen_path?: string;
+ remark?: string;
+}
+
+export interface CodeGenBusinessResult extends CodeGenBusinessParams {
+ id: number;
+ created_time: string;
+ updated_time: string;
+}
+
+export interface CodeGenColumnParams {
+ name: string;
+ comment?: string;
+ type: string;
+ default?: string;
+ sort: number;
+ length: number;
+ is_pk: boolean;
+ is_nullable: boolean;
+ gen_business_id: number;
+}
+
+export interface CodeGenColumnResult extends CodeGenColumnParams {
+ id: number;
+ pd_type: string;
+}
+
+export interface CodeGenBusinessImportParams {
+ app: string;
+ table_schema: string;
+ table_name: string;
+}
+
+export async function getCodeGenBusinessDetailApi(pk: number) {
+ return requestClient.get(
+ `/api/v1/code-generation/businesses/${pk}`,
+ );
+}
+
+export async function getAllCodeGenBusinessApi() {
+ return requestClient.get(
+ '/api/v1/code-generation/businesses/all',
+ );
+}
+
+export async function createCodeGenBusinessApi(data: CodeGenBusinessParams) {
+ return requestClient.post(`/api/v1/code-generation/businesses`, data);
+}
+
+export async function updateCodeGenBusinessApi(
+ pk: number,
+ data: CodeGenBusinessParams,
+) {
+ return requestClient.put(`/api/v1/code-generation/businesses/${pk}`, data);
+}
+
+export async function deleteCodeGenBusinessApi(pk: number) {
+ return requestClient.delete(`/api/v1/code-generation/businesses/${pk}`);
+}
+
+export async function getCodeGenBusinessListApi(
+ params: QueryCodeGenBusinessParams,
+) {
+ return requestClient.get>(
+ `/api/v1/code-generation/businesses`,
+ { params },
+ );
+}
+
+export async function getAllCodeGenBusinessColumnApi(pk: number) {
+ return requestClient.get>(
+ `/api/v1/code-generation/businesses/${pk}/columns`,
+ );
+}
+
+export async function getAllCodeGenColumnTypeApi() {
+ return requestClient.get(`/api/v1/code-generation/columns/types`);
+}
+
+export async function getCodeGenColumnDetailApi(pk: number) {
+ return requestClient.get(`/api/v1/code-generation/columns/${pk}`);
+}
+
+export async function createCodeGenColumnApi(data: CodeGenColumnParams) {
+ return requestClient.post(`/api/v1/code-generation/columns`, data);
+}
+
+export async function updateCodeGenColumnApi(
+ pk: number,
+ data: CodeGenColumnParams,
+) {
+ return requestClient.put(`/api/v1/code-generation/columns/${pk}`, data);
+}
+
+export async function deleteCodeGenColumnApi(pk: number) {
+ return requestClient.delete(`/api/v1/code-generation/columns/${pk}`);
+}
+
+export async function getCodeGenDbTableApi(params: Recordable) {
+ return requestClient.get(
+ `/api/v1/code-generation/generations/tables`,
+ {
+ params,
+ },
+ );
+}
+
+export async function importCodeGenDbTableApi(
+ data: CodeGenBusinessImportParams,
+) {
+ return requestClient.post(
+ `/api/v1/code-generation/generations/imports`,
+ data,
+ );
+}
+
+export async function previewCodeGenApi(pk: number) {
+ return requestClient.get(`/api/v1/code-generation/generations/${pk}/preview`);
+}
+
+export async function getCodeGenPathApi(pk: number) {
+ return requestClient.get(
+ `/api/v1/code-generation/generations/${pk}/paths`,
+ );
+}
+
+export async function generateCodeApi(pk: number) {
+ return requestClient.post(`/api/v1/code-generation/generations/${pk}`);
+}
+
+export async function downloadCodeApi(pk: number) {
+ return requestClient.download(
+ `/api/v1/code-generation/generations/${pk}`,
+ );
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/langs/en-US/code_generator.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/langs/en-US/code_generator.json
new file mode 100644
index 0000000..15f6abe
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/langs/en-US/code_generator.json
@@ -0,0 +1,3 @@
+{
+ "menu": "Code Generator"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/langs/zh-CN/code_generator.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/langs/zh-CN/code_generator.json
new file mode 100644
index 0000000..c524130
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/langs/zh-CN/code_generator.json
@@ -0,0 +1,3 @@
+{
+ "menu": "代码生成"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/package.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/package.json
new file mode 100644
index 0000000..e808c38
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@fba-ui/code-generator",
+ "version": "5.7.0",
+ "private": true,
+ "type": "module"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/routes/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/routes/index.ts
new file mode 100644
index 0000000..f1216fe
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/routes/index.ts
@@ -0,0 +1,17 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+ {
+ name: 'PluginCodeGenerator',
+ path: '/plugins/code-generator',
+ component: () => import('../views/index.vue'),
+ meta: {
+ title: $t('code_generator.menu'),
+ icon: 'tabler:code',
+ },
+ },
+];
+
+export default routes;
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/views/column.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/views/column.vue
new file mode 100644
index 0000000..5dc93f2
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/views/column.vue
@@ -0,0 +1,266 @@
+
+
+
+
+
+
+
+
+ 主键 ID 列状态:自动生成
+ 默认时间列状态:
+
+ {{ drawerApi.getData().datetime_mixin ? '已配置' : '未配置' }}
+
+
+
+
+
+ 新增模型列
+
+
+
+
+ 生成
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/views/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/views/data.ts
new file mode 100644
index 0000000..23828db
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/views/data.ts
@@ -0,0 +1,415 @@
+import type { CodeGenBusinessResult, CodeGenColumnResult } from '../api';
+
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
+
+import { ref } from 'vue';
+
+import { $t } from '@vben/locales';
+
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+import {
+ getAllCodeGenBusinessApi,
+ getAllCodeGenColumnTypeApi,
+ getCodeGenDbTableApi,
+} from '../api';
+
+export const querySchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'table_name',
+ label: '表名称',
+ },
+];
+
+export function useColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ {
+ field: 'seq',
+ title: $t('common.table.id'),
+ type: 'seq',
+ width: 50,
+ },
+ { field: 'app_name', title: '应用名称' },
+ { field: 'table_name', title: '表名称' },
+ { field: 'table_comment', title: '表描述' },
+ {
+ field: 'doc_comment',
+ title: '文档描述',
+ titleSuffix: { content: '用于 python 代码类、函数、参数文档' },
+ },
+ {
+ field: 'class_name',
+ title: '实体类名',
+ titleSuffix: {
+ content: '用于 python 代码基础类名',
+ },
+ },
+ {
+ field: 'schema_name',
+ title: 'Schema 类名',
+ titleSuffix: {
+ content: '用于 python Schema 代码基础类名',
+ },
+ },
+ {
+ field: 'filename',
+ title: '文件名',
+ titleSuffix: {
+ content: '用于 python 代码基础文件名',
+ },
+ },
+ { field: 'api_version', title: '版本' },
+ {
+ field: 'tag',
+ title: '标签',
+ titleSuffix: {
+ content: '用于 API 文档分组标签',
+ },
+ },
+ {
+ field: 'gen_path',
+ title: '生成路径',
+ titleSuffix: {
+ content: '默认生成到 app 根路径,也可以自定义生成路径',
+ },
+ align: 'left',
+ },
+ { field: 'remark', title: $t('common.table.mark'), align: 'left' },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 150,
+ cellRender: {
+ attrs: {
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: [
+ {
+ code: 'details',
+ text: '详情',
+ },
+ 'edit',
+ 'delete',
+ ],
+ },
+ },
+ ];
+}
+
+const tableSchemaValue = ref();
+export const importSchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'app',
+ label: '应用名称',
+ help: '将代码生成到指定应用下',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'table_schema',
+ label: '数据库名',
+ help: '当前服务所连接的数据库名',
+ rules: 'required',
+ },
+ {
+ component: 'ApiSelect',
+ componentProps: {
+ allowClear: true,
+ api: getCodeGenDbTableApi,
+ params: { table_schema: tableSchemaValue },
+ afterFetch: (data: { table_comment: string; table_name: string }[]) => {
+ return data.map((item: any) => ({
+ label: item.table_comment || item.table_name,
+ value: item.table_name,
+ }));
+ },
+ class: 'w-full',
+ },
+ dependencies: {
+ disabled: (values) => {
+ return !values.table_schema;
+ },
+ trigger(values) {
+ tableSchemaValue.value = values.table_schema;
+ },
+ triggerFields: ['table_schema'],
+ },
+ fieldName: 'table_name',
+ label: '数据库表名',
+ rules: 'required',
+ },
+];
+
+export const editSchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'app_name',
+ label: '应用名称',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'table_name',
+ label: '表名称',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'table_comment',
+ label: '表描述',
+ },
+ {
+ component: 'Input',
+ fieldName: 'doc_comment',
+ label: '文档描述',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'class_name',
+ label: '实体类名',
+ },
+ {
+ component: 'Input',
+ fieldName: 'schema_name',
+ label: 'Schema 类名',
+ },
+ {
+ component: 'Input',
+ fieldName: 'filename',
+ label: '文件名',
+ },
+ {
+ component: 'Input',
+ fieldName: 'api_version',
+ label: 'API 版本',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'tag',
+ label: '标签',
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: $t('common.enabled'), value: true },
+ // { label: $t('common.disabled'), value: false },
+ // ],
+
+ options: getDictOptions(DictEnum.SYS_CHOOSE),
+ optionType: 'button',
+ },
+ fieldName: 'datetime_mixin',
+ label: '默认时间列',
+ rules: 'required',
+ },
+ {
+ component: 'Textarea',
+ fieldName: 'gen_path',
+ label: '生成路径',
+ },
+ {
+ component: 'Textarea',
+ fieldName: 'remark',
+ label: $t('common.table.mark'),
+ },
+];
+
+export function useColumnColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ {
+ field: 'seq',
+ title: $t('common.table.id'),
+ type: 'seq',
+ width: 50,
+ },
+ {
+ field: 'name',
+ title: '名称',
+ },
+ {
+ field: 'is_pk',
+ title: '是否主键',
+ cellRender: {
+ name: 'CellTag',
+ // options: [
+ // { color: 'success', label: $t('common.enabled'), value: true },
+ // { color: 'error', label: $t('common.disabled'), value: false },
+ // ],
+
+ options: getDictOptions(DictEnum.SYS_CHOOSE),
+ },
+ },
+ {
+ field: 'comment',
+ title: '描述',
+ },
+ {
+ field: 'type',
+ title: 'SQLA 类型',
+ },
+ {
+ field: 'pd_type',
+ title: 'Pydantic 类型',
+ },
+ {
+ field: 'default',
+ title: '默认值',
+ },
+ {
+ field: 'sort',
+ title: '排序',
+ },
+ {
+ field: 'length',
+ title: '长度',
+ },
+ {
+ field: 'is_nullable',
+ title: '选填',
+ cellRender: {
+ name: 'CellTag',
+ // options: [
+ // { color: 'success', label: $t('common.enabled'), value: true },
+ // { color: 'error', label: $t('common.disabled'), value: false },
+ // ],
+
+ options: getDictOptions(DictEnum.SYS_CHOOSE),
+ },
+ },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 120,
+ cellRender: {
+ attrs: {
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: ['edit', 'delete'],
+ },
+ },
+ ];
+}
+
+export const columnSchema: VbenFormSchema[] = [
+ {
+ component: 'ApiSelect',
+ componentProps: {
+ allowClear: true,
+ api: getAllCodeGenBusinessApi,
+ afterFetch: (data: CodeGenBusinessResult[]) => {
+ return data.map((item: CodeGenBusinessResult) => ({
+ label: item.app_name,
+ value: item.id,
+ }));
+ },
+ disabled: true,
+ class: 'w-full',
+ },
+ fieldName: 'gen_business_id',
+ label: '所属业务',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '列名称',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'comment',
+ label: '列描述',
+ },
+ {
+ component: 'ApiSelect',
+ componentProps: {
+ allowClear: true,
+ api: getAllCodeGenColumnTypeApi,
+ afterFetch: (data: string[]) => {
+ return data.map((item: string) => ({
+ label: item,
+ value: item,
+ }));
+ },
+ class: 'w-full',
+ },
+ fieldName: 'type',
+ label: 'SQLA 列类型',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'default',
+ label: '列默认值',
+ },
+ {
+ component: 'InputNumber',
+ componentProps: {
+ class: 'w-full',
+ min: 0,
+ },
+ fieldName: 'sort',
+ label: '列排序',
+ rules: 'required',
+ },
+ {
+ component: 'InputNumber',
+ componentProps: {
+ class: 'w-full',
+ min: 0,
+ },
+ fieldName: 'length',
+ label: '列长度',
+ rules: 'required',
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: $t('common.enabled'), value: true },
+ // { label: $t('common.disabled'), value: false },
+ // ],
+
+ options: getDictOptions(DictEnum.SYS_CHOOSE),
+ optionType: 'button',
+ },
+ defaultValue: false,
+ fieldName: 'is_pk',
+ label: '是否主键',
+ rules: 'required',
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: $t('common.enabled'), value: true },
+ // { label: $t('common.disabled'), value: false },
+ // ],
+ options: getDictOptions(DictEnum.SYS_CHOOSE),
+ optionType: 'button',
+ },
+ defaultValue: false,
+ fieldName: 'is_nullable',
+ label: '是否选填',
+ rules: 'required',
+ },
+];
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/views/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/views/index.vue
new file mode 100644
index 0000000..c8675a4
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/views/index.vue
@@ -0,0 +1,224 @@
+
+
+
+
+
+
+ modalApi.setData(null).open()">
+
+ 添加
+
+
+
+ 导入
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/views/preview.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/views/preview.vue
new file mode 100644
index 0000000..650431a
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/code_generator/views/preview.vue
@@ -0,0 +1,199 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/api/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/api/index.ts
new file mode 100644
index 0000000..f715b63
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/api/index.ts
@@ -0,0 +1,28 @@
+import type { Recordable } from '@vben/types';
+
+import { requestClient } from '#/api/request';
+
+export interface ConfigParams {
+ id: string;
+ name: string;
+ type?: string;
+ key: string;
+ value: string;
+ is_frontend: boolean;
+ remark?: string;
+}
+
+export interface ConfigResult extends ConfigParams {
+ created_time: string;
+ updated_time?: string;
+}
+
+export async function getAllConfigApi(params: Recordable) {
+ return requestClient.get('/api/v1/sys/configs/all', {
+ params,
+ });
+}
+
+export async function updateConfigApi(params: ConfigParams[]) {
+ return requestClient.put('/api/v1/sys/configs', params);
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/langs/en-US/config.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/langs/en-US/config.json
new file mode 100644
index 0000000..23f568b
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/langs/en-US/config.json
@@ -0,0 +1,3 @@
+{
+ "menu": "Parameter config"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/langs/zh-CN/config.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/langs/zh-CN/config.json
new file mode 100644
index 0000000..68e4614
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/langs/zh-CN/config.json
@@ -0,0 +1,3 @@
+{
+ "menu": "参数配置"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/package.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/package.json
new file mode 100644
index 0000000..b5e99c0
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@fba-ui/config",
+ "version": "5.7.0",
+ "private": true,
+ "type": "module"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/routes/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/routes/index.ts
new file mode 100644
index 0000000..c6bd77f
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/routes/index.ts
@@ -0,0 +1,17 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+ {
+ name: 'PluginConfig',
+ path: '/plugins/config',
+ component: () => import('../views/index.vue'),
+ meta: {
+ title: $t('config.menu'),
+ icon: 'codicon:symbol-parameter',
+ },
+ },
+];
+
+export default routes;
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/data.ts
new file mode 100644
index 0000000..bc0e28b
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/data.ts
@@ -0,0 +1,212 @@
+import type { VbenFormSchema } from '#/adapter/form';
+
+import { z } from '#/adapter/form';
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+export const userSecuritySchema: VbenFormSchema[] = [
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ // options: [
+ // { label: $t('common.enabled'), value: '1' },
+ // { label: $t('common.disabled'), value: '0' },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS, { asString: true }),
+ optionType: 'button',
+ },
+ defaultValue: '0',
+ fieldName: 'USER_SECURITY_CONFIG_STATUS',
+ label: '状态',
+ help: '默认使用本地配置,当启用时,将使用此配置',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'USER_LOCK_THRESHOLD',
+ label: '密码错误锁定阈值',
+ description: '用户连续登录失败达到此次数后将被锁定,0 表示禁用锁定',
+ renderComponentContent: () => ({
+ suffix: () => '次',
+ }),
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'USER_LOCK_SECONDS',
+ label: '密码错误锁定时长(秒)',
+ description: '用户被锁定后自动解锁的时间',
+ renderComponentContent: () => ({
+ suffix: () => '秒',
+ }),
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'USER_PASSWORD_EXPIRY_DAYS',
+ label: '密码有效期(天)',
+ description: '密码强制修改周期,0 表示永不过期',
+ renderComponentContent: () => ({
+ suffix: () => '天',
+ }),
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'USER_PASSWORD_REMINDER_DAYS',
+ label: '密码到期提醒(天)',
+ description: '密码到期前多少天提醒用户修改密码,0 表示不提醒',
+ renderComponentContent: () => ({
+ suffix: () => '天',
+ }),
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'USER_PASSWORD_HISTORY_CHECK_COUNT',
+ label: '密码历史检查次数',
+ description: '新密码不能与最近 N 次使用的密码相同',
+ renderComponentContent: () => ({
+ suffix: () => '次',
+ }),
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'USER_PASSWORD_MIN_LENGTH',
+ label: '密码最小长度',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'USER_PASSWORD_MAX_LENGTH',
+ label: '密码最大长度',
+ rules: 'required',
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ // options: [
+ // { color: 'success', label: $t('common.enabled'), value: 'true' },
+ // { color: 'error', label: $t('common.disabled'), value: 'false' },
+ // ],
+ options: getDictOptions(DictEnum.SYS_CHOOSE, { asString: true }),
+ optionType: 'button',
+ },
+ defaultValue: 'false',
+ fieldName: 'USER_PASSWORD_REQUIRE_SPECIAL_CHAR',
+ label: '密码必须包含特殊字符',
+ labelClass: 'float-left',
+ rules: 'required',
+ },
+];
+
+export const loginSchema: VbenFormSchema[] = [
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ // options: [
+ // { label: $t('common.enabled'), value: '1' },
+ // { label: $t('common.disabled'), value: '0' },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS, { asString: true }),
+ optionType: 'button',
+ },
+ defaultValue: '0',
+ fieldName: 'LOGIN_CONFIG_STATUS',
+ label: '状态',
+ help: '默认使用本地配置,当启用时,将使用此配置',
+ rules: 'required',
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ // options: [
+ // { color: 'success', label: $t('common.enabled'), value: 'true' },
+ // { color: 'error', label: $t('common.disabled'), value: 'false' },
+ // ],
+ options: getDictOptions(DictEnum.SYS_CHOOSE, { asString: true }),
+ optionType: 'button',
+ },
+ defaultValue: 'true',
+ fieldName: 'LOGIN_CAPTCHA_ENABLED',
+ label: '验证码开关',
+ description: '是否启用登录验证码',
+ labelClass: 'float-left',
+ rules: 'required',
+ },
+];
+
+export const emailSchema: VbenFormSchema[] = [
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ // options: [
+ // { label: $t('common.enabled'), value: '1' },
+ // { label: $t('common.disabled'), value: '0' },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS, { asString: true }),
+ optionType: 'button',
+ },
+ defaultValue: '0',
+ fieldName: 'EMAIL_STATUS',
+ label: '状态',
+ help: '默认使用本地配置,当启用时,将使用此配置',
+ rules: 'required',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ options: [
+ {
+ label: 'SMTP',
+ value: '0',
+ },
+ ],
+ },
+ defaultValue: '0',
+ fieldName: 'EMAIL_PROTOCOL',
+ label: '邮件协议',
+ rules: 'required',
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ // options: [
+ // { color: 'success', label: $t('common.enabled'), value: 'true' },
+ // { color: 'error', label: $t('common.disabled'), value: 'false' },
+ // ],
+ options: getDictOptions(DictEnum.SYS_CHOOSE, { asString: true }),
+ optionType: 'button',
+ },
+ defaultValue: '1',
+ fieldName: 'EMAIL_SSL',
+ label: 'SSL 加密',
+ labelClass: 'float-left',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'EMAIL_HOST',
+ label: '服务器地址',
+ rules: 'required',
+ },
+ {
+ component: 'InputNumber',
+ fieldName: 'EMAIL_PORT',
+ label: '服务器端口',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'EMAIL_USERNAME',
+ label: '邮箱账号',
+ rules: z.string().email({ message: '无效的邮箱地址' }),
+ },
+ {
+ component: 'InputPassword',
+ fieldName: 'EMAIL_PASSWORD',
+ label: '邮箱密码',
+ help: '账号授权密码',
+ rules: 'required',
+ },
+];
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/email.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/email.vue
new file mode 100644
index 0000000..4d949c8
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/email.vue
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+ {
+ editButtonShow = false;
+ formApi.setState({ commonConfig: { disabled: false } });
+ }
+ "
+ >
+
+ 修改
+
+
+
+ 保存
+
+ {
+ editButtonShow = true;
+ formApi.setState({ commonConfig: { disabled: true } });
+ fetchConfigList();
+ }
+ "
+ >
+
+ 取消
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/index.vue
new file mode 100644
index 0000000..eb3d284
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/index.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/login.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/login.vue
new file mode 100644
index 0000000..fdfbd96
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/login.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+ {
+ editButtonShow = false;
+ formApi.setState({ commonConfig: { disabled: false } });
+ }
+ "
+ >
+
+ 修改
+
+
+
+ 保存
+
+ {
+ editButtonShow = true;
+ formApi.setState({ commonConfig: { disabled: true } });
+ fetchConfigList();
+ }
+ "
+ >
+
+ 取消
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/user-security.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/user-security.vue
new file mode 100644
index 0000000..8811863
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/config/views/user-security.vue
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+ {
+ editButtonShow = false;
+ formApi.setState({ commonConfig: { disabled: false } });
+ }
+ "
+ >
+
+ 修改
+
+
+
+ 保存
+
+ {
+ editButtonShow = true;
+ formApi.setState({ commonConfig: { disabled: true } });
+ fetchConfigList();
+ }
+ "
+ >
+
+ 取消
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/api/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/api/index.ts
new file mode 100644
index 0000000..d383312
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/api/index.ts
@@ -0,0 +1,114 @@
+import type { PaginationResult } from '#/types';
+
+import { requestClient } from '#/api/request';
+
+export interface DictTypeParams {
+ name?: string;
+ code?: string;
+ status?: number;
+ page?: number;
+ size?: number;
+}
+
+export interface DictTypeResult {
+ id: number;
+ name: string;
+ code: string;
+ status: number;
+ remark?: string;
+ created_time: string;
+ updated_time?: string;
+}
+
+export interface CreateDictTypeParams {
+ name: string;
+ code: string;
+ status: number;
+ remark: string;
+}
+
+export interface DictDataParams {
+ type_id?: number;
+ label?: string;
+ status?: number;
+}
+
+export interface DictDataResult {
+ id: number;
+ type_id: number;
+ label: string;
+ value: string;
+ color?: string;
+ sort: number;
+ status: number;
+ remark: string;
+}
+
+export interface CreateDictDataParams {
+ type_id: number;
+ label: string;
+ value: string;
+ sort: number;
+ status: number;
+ remark?: string;
+}
+
+export async function getAllDictTypeApi() {
+ return await requestClient.get(
+ '/api/v1/sys/dict-types/all',
+ );
+}
+
+export async function getDictTypeListApi(params: DictTypeParams) {
+ return await requestClient.get>(
+ '/api/v1/sys/dict-types',
+ { params },
+ );
+}
+
+export async function createDictTypeApi(data: CreateDictTypeParams) {
+ return await requestClient.post('/api/v1/sys/dict-types', data);
+}
+
+export async function updateDictTypeApi(
+ pk: number,
+ data: CreateDictTypeParams,
+) {
+ return await requestClient.put(`/api/v1/sys/dict-types/${pk}`, data);
+}
+
+export async function deleteDictTypeApi(pks: number[]) {
+ return await requestClient.delete('/api/v1/sys/dict-types', {
+ data: { pks },
+ });
+}
+
+export async function getDictDataDetailApi(code: string) {
+ return await requestClient.get(
+ `/api/v1/sys/dict-datas/type-codes/${code}`,
+ );
+}
+
+export async function getDictDataListApi(params: DictDataParams) {
+ return await requestClient.get>(
+ '/api/v1/sys/dict-datas',
+ { params },
+ );
+}
+
+export async function createDictDataApi(data: CreateDictDataParams) {
+ return await requestClient.post('/api/v1/sys/dict-datas', data);
+}
+
+export async function updateDictDataApi(
+ pk: number,
+ data: CreateDictDataParams,
+) {
+ return await requestClient.put(`/api/v1/sys/dict-datas/${pk}`, data);
+}
+
+export async function deleteDictDataApi(pks: number[]) {
+ return await requestClient.delete('/api/v1/sys/dict-datas', {
+ data: { pks },
+ });
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/langs/en-US/dict.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/langs/en-US/dict.json
new file mode 100644
index 0000000..e2c6507
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/langs/en-US/dict.json
@@ -0,0 +1,3 @@
+{
+ "menu": "Dict"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/langs/zh-CN/dict.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/langs/zh-CN/dict.json
new file mode 100644
index 0000000..779bee0
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/langs/zh-CN/dict.json
@@ -0,0 +1,3 @@
+{
+ "menu": "字典管理"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/package.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/package.json
new file mode 100644
index 0000000..d49f804
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@fba-ui/dict",
+ "version": "5.7.0",
+ "private": true,
+ "type": "module"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/routes/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/routes/index.ts
new file mode 100644
index 0000000..cf1b925
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/routes/index.ts
@@ -0,0 +1,17 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+ {
+ name: 'PluginDict',
+ path: '/plugins/dict',
+ component: () => import('../views/index.vue'),
+ meta: {
+ title: $t('dict.menu'),
+ icon: 'fluent-mdl2:dictionary',
+ },
+ },
+];
+
+export default routes;
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/data.ts
new file mode 100644
index 0000000..5ba7ea0
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/data.ts
@@ -0,0 +1,243 @@
+import type { DictDataResult, DictTypeResult } from '../api';
+
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
+
+import { h } from 'vue';
+
+import { $t } from '@vben/locales';
+
+import { Tag } from 'antdv-next';
+
+import { z } from '#/adapter/form';
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+import { getAllDictTypeApi } from '../api';
+
+export const queryDictTypeSchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '类型名称',
+ },
+];
+
+export function useDictTypeColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ {
+ field: 'seq',
+ title: $t('common.table.id'),
+ type: 'seq',
+ width: 50,
+ },
+ { field: 'name', title: '名称' },
+ { field: 'remark', title: $t('common.table.mark'), align: 'left' },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 130,
+ cellRender: {
+ attrs: {
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: ['edit', 'delete'],
+ },
+ },
+ ];
+}
+
+export const dictTypeSchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '类型名称',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'code',
+ label: '类型编码',
+ rules: z.string().regex(/^[A-Z_]+$/i, {
+ message: '只能包含英文字母和下划线',
+ }),
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: $t('common.enabled'), value: 1 },
+ // { label: $t('common.disabled'), value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS),
+ optionType: 'button',
+ },
+ defaultValue: 1,
+ fieldName: 'status',
+ label: '状态',
+ rules: 'required',
+ },
+ {
+ component: 'Textarea',
+ fieldName: 'remark',
+ label: '备注',
+ },
+];
+
+export const queryDictDataSchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'label',
+ label: '数据标签',
+ },
+];
+
+export function useDictDataColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ {
+ field: 'seq',
+ title: $t('common.table.id'),
+ type: 'seq',
+ width: 50,
+ },
+ { field: 'label', title: '标签' },
+ { field: 'value', title: '值' },
+ {
+ field: 'color',
+ title: '标签样式',
+ slots: {
+ default: ({ row }: { row: any }) => {
+ return h(Tag, { color: row.color }, { default: () => row.label });
+ },
+ },
+ },
+ {
+ field: 'status',
+ title: '状态',
+ cellRender: {
+ name: 'CellTag',
+ },
+ },
+ { field: 'sort', title: '排序' },
+ { field: 'remark', title: $t('common.table.mark'), align: 'left' },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 130,
+ cellRender: {
+ attrs: {
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: ['edit', 'delete'],
+ },
+ },
+ ];
+}
+
+const COLOR_OPTIONS = [
+ { value: 'processing', label: '主要(primary)' },
+ { value: 'success', label: '成功(success)' },
+ { value: 'error', label: '危险(danger)' },
+ { value: 'warning', label: '警告(warning)' },
+ { value: 'magenta', label: 'magenta' },
+ { value: 'red', label: 'red' },
+ { value: 'volcano', label: 'volcano' },
+ { value: 'orange', label: 'orange' },
+ { value: 'gold', label: 'gold' },
+ { value: 'lime', label: 'lime' },
+ { value: 'green', label: 'green' },
+ { value: 'cyan', label: 'cyan' },
+ { value: 'blue', label: 'blue' },
+ { value: 'geekblue', label: 'geekblue' },
+ { value: 'purple', label: 'purple' },
+ { value: 'default', label: '默认(default)' },
+ { value: 'pink', label: 'pink' },
+];
+
+export const dictDataSchema: VbenFormSchema[] = [
+ {
+ component: 'ApiSelect',
+ componentProps: {
+ api: getAllDictTypeApi,
+ class: 'w-full',
+ labelField: 'name',
+ valueField: 'id',
+ disabled: true,
+ },
+ fieldName: 'type_id',
+ label: '字典类型',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'label',
+ label: '数据标签',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'value',
+ label: '数据值',
+ rules: 'required',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ class: 'w-full',
+ options: COLOR_OPTIONS,
+ optionRender: ({
+ option,
+ }: {
+ option: { data?: { label?: string; value?: string } };
+ }) => {
+ const optionData = option?.data;
+ const color = optionData?.value || 'default';
+ const label = optionData?.label || color;
+ return h(Tag, { color }, { default: () => label });
+ },
+ },
+ fieldName: 'color',
+ label: '标签样式',
+ },
+ {
+ component: 'InputNumber',
+ componentProps: {
+ class: 'w-full',
+ min: 0,
+ },
+ fieldName: 'sort',
+ label: '排序',
+ rules: 'required',
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: $t('common.enabled'), value: 1 },
+ // { label: $t('common.disabled'), value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS),
+ optionType: 'button',
+ },
+ defaultValue: 1,
+ fieldName: 'status',
+ label: '状态',
+ rules: 'required',
+ },
+ {
+ component: 'Textarea',
+ fieldName: 'remark',
+ label: '备注',
+ },
+];
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/dict-data.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/dict-data.vue
new file mode 100644
index 0000000..13b6bc5
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/dict-data.vue
@@ -0,0 +1,188 @@
+
+
+
+
+
+ modalApi.setData({ type_id: dictTypeId }).open()"
+ >
+
+ 新增
+
+
+
+
+
+ 点击字典类型行以获取字典数据
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/dict-type.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/dict-type.vue
new file mode 100644
index 0000000..313f8d5
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/dict-type.vue
@@ -0,0 +1,187 @@
+
+
+
+
+
+ modalApi.setData(null).open()">
+
+ 新增
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/index.vue
new file mode 100644
index 0000000..fd9f6b0
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/index.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/mitt.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/mitt.ts
new file mode 100644
index 0000000..020d010
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/dict/views/mitt.ts
@@ -0,0 +1,7 @@
+import mitt from 'mitt';
+
+type Events = {
+ rowClick: number;
+};
+
+export const emitter = mitt();
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/email/api/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/email/api/index.ts
new file mode 100644
index 0000000..14b2244
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/email/api/index.ts
@@ -0,0 +1,12 @@
+import { requestClient } from '#/api/request';
+
+export interface emailCaptchaParams {
+ recipients: string;
+}
+
+/**
+ * 获取邮箱验证码
+ */
+export async function getEmailCaptchaApi(data: emailCaptchaParams) {
+ return requestClient.post('/api/v1/emails/captcha', data);
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/email/package.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/email/package.json
new file mode 100644
index 0000000..53edd71
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/email/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@fba-ui/email",
+ "version": "5.7.0",
+ "private": true,
+ "type": "module"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/api/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/api/index.ts
new file mode 100644
index 0000000..4779f86
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/api/index.ts
@@ -0,0 +1,56 @@
+import type { PaginationResult } from '#/types';
+
+import { requestClient } from '#/api/request';
+
+export interface SysNoticeParams {
+ title?: string;
+ type?: number;
+ status?: number;
+ page?: number;
+ size?: number;
+}
+
+export interface SysNoticeResult {
+ id: number;
+ title: string;
+ type: number;
+ status: number;
+ content: string;
+ created_time: string;
+ updated_time?: string;
+}
+
+export interface CreateSysNoticeParams {
+ title: string;
+ type: number;
+ status: number;
+ content: string;
+}
+
+export type UpdateSysNoticeParams = CreateSysNoticeParams;
+
+export async function getNoticeListApi(params: SysNoticeParams) {
+ return await requestClient.get>(
+ '/api/v1/sys/notices',
+ { params },
+ );
+}
+
+export async function getSysNoticeApi(pk: number) {
+ return requestClient.get(`/api/v1/sys/notices/${pk}`);
+}
+
+export async function createSysNoticeApi(data: CreateSysNoticeParams) {
+ return requestClient.post(`/api/v1/sys/notices`, data);
+}
+
+export async function updateSysNoticeApi(
+ pk: number,
+ data: UpdateSysNoticeParams,
+) {
+ return requestClient.put(`/api/v1/sys/notices/${pk}`, data);
+}
+
+export async function deleteSysNoticeApi(pks: number[]) {
+ return requestClient.delete('/api/v1/sys/notices', { data: { pks } });
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/langs/en-US/notice.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/langs/en-US/notice.json
new file mode 100644
index 0000000..219c050
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/langs/en-US/notice.json
@@ -0,0 +1,3 @@
+{
+ "menu": "Notice"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/langs/zh-CN/notice.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/langs/zh-CN/notice.json
new file mode 100644
index 0000000..3383a10
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/langs/zh-CN/notice.json
@@ -0,0 +1,3 @@
+{
+ "menu": "通知公告"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/package.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/package.json
new file mode 100644
index 0000000..335b81c
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@fba-ui/notice",
+ "version": "5.7.0",
+ "private": true,
+ "type": "module"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/routes/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/routes/index.ts
new file mode 100644
index 0000000..1db6b0d
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/routes/index.ts
@@ -0,0 +1,17 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+ {
+ name: 'PluginNotice',
+ path: '/plugins/notice',
+ component: () => import('../views/index.vue'),
+ meta: {
+ title: $t('notice.menu'),
+ icon: 'fe:notice-push',
+ },
+ },
+];
+
+export default routes;
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/views/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/views/data.ts
new file mode 100644
index 0000000..4b3aa7d
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/views/data.ts
@@ -0,0 +1,172 @@
+import type { SysNoticeResult } from '../api';
+
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
+
+import { h } from 'vue';
+
+import { MarkdownEditor } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+export const querySchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'title',
+ label: '标题',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ // options: [
+ // {
+ // label: '通知',
+ // value: 0,
+ // },
+ // {
+ // label: '公告',
+ // value: 1,
+ // },
+ // ],
+ options: getDictOptions(DictEnum.NOTICE),
+ },
+ fieldName: 'type',
+ label: '类型',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ // options: [
+ // {
+ // label: '已启用',
+ // value: 1,
+ // },
+ // {
+ // label: '已停用',
+ // value: 0,
+ // },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS),
+ },
+ fieldName: 'status',
+ label: $t('common.form.status'),
+ },
+];
+
+export function useColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ {
+ field: 'seq',
+ title: $t('common.table.id'),
+ type: 'seq',
+ width: 50,
+ },
+ { field: 'title', title: '标题' },
+ {
+ field: 'type',
+ title: '类型',
+ cellRender: {
+ name: 'CellTag',
+ // options: [
+ // { color: 'success', label: '通知', value: 0 },
+ // { color: 'warning', label: '公告', value: 1 },
+ // ],
+
+ options: getDictOptions(DictEnum.NOTICE),
+ },
+ },
+ {
+ field: 'status',
+ title: '状态',
+ cellRender: {
+ name: 'CellTag',
+ },
+ },
+ {
+ field: 'created_time',
+ title: $t('common.table.created_time'),
+ width: 168,
+ },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 150,
+ cellRender: {
+ attrs: {
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: [
+ {
+ code: 'preview',
+ text: '预览',
+ },
+ 'edit',
+ 'delete',
+ ],
+ },
+ },
+ ];
+}
+
+export const schema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ formItemClass: 'md:col-span-2',
+ fieldName: 'title',
+ label: '标题',
+ rules: 'required',
+ },
+ {
+ component: 'RadioGroup',
+ formItemClass: 'col-span-1 md:col-span-1',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: '通知', value: 0 },
+ // { label: '公告', value: 1 },
+ // ],
+
+ options: getDictOptions(DictEnum.NOTICE),
+ optionType: 'button',
+ },
+ defaultValue: 0,
+ fieldName: 'type',
+ label: '类型',
+ rules: 'required',
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: $t('common.enabled'), value: 1 },
+ // { label: $t('common.disabled'), value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS),
+ optionType: 'button',
+ },
+ defaultValue: 1,
+ fieldName: 'status',
+ label: '状态',
+ rules: 'required',
+ },
+ {
+ component: h(MarkdownEditor),
+ modelPropName: 'value',
+ componentProps: {
+ class: 'w-full',
+ },
+ formItemClass: 'md:col-span-2',
+ fieldName: 'content',
+ label: '内容',
+ rules: 'required',
+ },
+];
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/views/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/views/index.vue
new file mode 100644
index 0000000..5bbf802
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/notice/views/index.vue
@@ -0,0 +1,199 @@
+
+
+
+
+
+
+ modalApi.setData(null).open()">
+
+ 新增通知公告
+
+
+
+
+
+
+
+ {{ preViewTitle }}
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/api/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/api/index.ts
new file mode 100644
index 0000000..41e2118
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/api/index.ts
@@ -0,0 +1,32 @@
+import { requestClient } from '#/api/request';
+
+export interface OAuth2BindingResult {
+ id: number;
+ sid: string;
+ source: string;
+ user_id: number;
+}
+
+export interface OAuth2BindingParams {
+ source: 'Github' | 'Google';
+}
+
+export async function getOAuth2Github() {
+ return requestClient.get('/api/v1/oauth2/github');
+}
+
+export async function getOAuth2Google() {
+ return requestClient.get('/api/v1/oauth2/google');
+}
+
+export async function getOAuth2Bindings() {
+ return requestClient.get('/api/v1/oauth2/me/bindings');
+}
+
+export async function getOAuth2BindingAuthUrl(params: OAuth2BindingParams) {
+ return requestClient.get('/api/v1/oauth2/me/binding', { params });
+}
+
+export async function deleteOAuth2Binding(params: OAuth2BindingParams) {
+ return requestClient.delete('/api/v1/oauth2/me/unbinding', { params });
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/package.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/package.json
new file mode 100644
index 0000000..3706f2c
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@fba-ui/oauth2",
+ "version": "5.7.0",
+ "private": true,
+ "type": "module"
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/routes/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/routes/index.ts
new file mode 100644
index 0000000..b52e187
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/routes/index.ts
@@ -0,0 +1,16 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+const routes: RouteRecordRaw[] = [
+ {
+ name: 'OAuth2Callback',
+ path: '/oauth2/callback',
+ component: () => import('../views/index.vue'),
+ meta: {
+ icon: 'mingcute:profile-line',
+ title: '第三方登录',
+ hideInMenu: true,
+ },
+ },
+];
+
+export default routes;
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/views/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/views/index.vue
new file mode 100644
index 0000000..67b4a41
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/views/index.vue
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/views/login.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/views/login.vue
new file mode 100644
index 0000000..a95f091
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/plugins/oauth2/views/login.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+ {{ $t('authentication.thirdPartyLogin') }}
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/preferences.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/preferences.ts
new file mode 100644
index 0000000..9300d45
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/preferences.ts
@@ -0,0 +1,30 @@
+import { defineOverridesPreferences } from '@vben/preferences';
+
+/**
+ * @description 项目配置文件
+ * 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置
+ * !!! 更改配置后请清空缓存,否则可能不生效
+ */
+export const overridesPreferences = defineOverridesPreferences({
+ // overrides
+ app: {
+ accessMode: 'backend',
+ name: import.meta.env.VITE_APP_TITLE,
+ enableRefreshToken: true,
+ },
+ footer: {
+ enable: false,
+ },
+ logo: {
+ source: 'https://wu-clan.github.io/picx-images-hosting/logo/fba.png',
+ },
+ shortcutKeys: {
+ enable: false,
+ },
+ theme: {
+ mode: 'auto',
+ },
+ widget: {
+ timezone: false,
+ },
+});
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/access.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/access.ts
new file mode 100644
index 0000000..f207a09
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/access.ts
@@ -0,0 +1,45 @@
+import type {
+ ComponentRecordType,
+ GenerateMenuAndRoutesOptions,
+} from '@vben/types';
+
+import { generateAccessible } from '@vben/access';
+import { preferences } from '@vben/preferences';
+
+import { message } from 'antdv-next';
+
+import { getAllMenusApi } from '#/api';
+import { BasicLayout, IFrameView } from '#/layouts';
+import { $t } from '#/locales';
+
+const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
+
+async function generateAccess(options: GenerateMenuAndRoutesOptions) {
+ const pageMap: ComponentRecordType = {
+ ...import.meta.glob('../views/**/*.vue'),
+ ...import.meta.glob('../plugins/**/*.vue'),
+ };
+
+ const layoutMap: ComponentRecordType = {
+ BasicLayout,
+ IFrameView,
+ };
+
+ return await generateAccessible(preferences.app.accessMode, {
+ ...options,
+ fetchMenuListAsync: async () => {
+ message.loading({
+ content: `${$t('common.loadingMenu')}...`,
+ duration: 1.5,
+ });
+ return await getAllMenusApi();
+ },
+ // 可以指定没有权限跳转403页面
+ forbiddenComponent,
+ // 如果 route.meta.menuVisibleWithForbidden = true
+ layoutMap,
+ pageMap,
+ });
+}
+
+export { generateAccess };
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/guard.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/guard.ts
new file mode 100644
index 0000000..cb3ec0a
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/guard.ts
@@ -0,0 +1,165 @@
+import type { Router } from 'vue-router';
+
+import { LOGIN_PATH } from '@vben/constants';
+import { preferences } from '@vben/preferences';
+import { useAccessStore, useUserStore } from '@vben/stores';
+import { startProgress, stopProgress } from '@vben/utils';
+
+import { accessRoutes, coreRouteNames } from '#/router/routes';
+import { useAuthStore, useWebSocketStore } from '#/store';
+
+import { generateAccess } from './access';
+
+/**
+ * 通用守卫配置
+ * @param router
+ */
+function setupCommonGuard(router: Router) {
+ // 记录已经加载的页面
+ const loadedPaths = new Set();
+
+ router.beforeEach((to) => {
+ to.meta.loaded = loadedPaths.has(to.path);
+
+ // 页面加载进度条
+ if (!to.meta.loaded && preferences.transition.progress) {
+ startProgress();
+ }
+ return true;
+ });
+
+ router.afterEach((to) => {
+ // 记录页面是否加载,如果已经加载,后续的页面切换动画等效果不在重复执行
+
+ loadedPaths.add(to.path);
+
+ // 关闭页面加载进度条
+ if (preferences.transition.progress) {
+ stopProgress();
+ }
+ });
+}
+
+/**
+ * 权限访问守卫配置
+ * @param router
+ */
+function setupAccessGuard(router: Router) {
+ router.beforeEach(async (to, from) => {
+ const accessStore = useAccessStore();
+ const userStore = useUserStore();
+ const authStore = useAuthStore();
+
+ // 优先处理 OAuth2 回调
+ if (
+ to.name === 'OAuth2Callback' ||
+ to.path === '/oauth2/callback' ||
+ window.location.pathname === '/oauth2/callback'
+ ) {
+ await authStore.oauth2Login();
+ // 为了兼容 vue-router hash 模式,这里直接重定向到域名
+ // 再由守卫自动完成默认地址重定向
+ window.location.replace(window.location.origin);
+ }
+
+ // 基本路由,这些路由不需要进入权限拦截
+ if (coreRouteNames.includes(to.name as string)) {
+ if (to.path === LOGIN_PATH && accessStore.accessToken) {
+ return decodeURIComponent(
+ (to.query?.redirect as string) ||
+ userStore.userInfo?.homePath ||
+ preferences.app.defaultHomePath,
+ );
+ }
+ return true;
+ }
+
+ // accessToken 检查
+ if (!accessStore.accessToken) {
+ // 明确声明忽略权限访问权限,则可以访问
+ if (to.meta.ignoreAccess) {
+ return true;
+ }
+
+ // 没有访问权限,跳转登录页面
+ if (to.fullPath !== LOGIN_PATH) {
+ return {
+ path: LOGIN_PATH,
+ // 如不需要,直接删除 query
+ query:
+ to.fullPath === preferences.app.defaultHomePath
+ ? {}
+ : { redirect: encodeURIComponent(to.fullPath) },
+ // 携带当前跳转的页面,登录后重新跳转该页面
+ replace: true,
+ };
+ }
+ return to;
+ }
+
+ // 是否已经生成过动态路由
+ if (accessStore.isAccessChecked) {
+ return true;
+ }
+
+ // 生成路由表
+ // 当前登录用户拥有的角色标识列表
+ const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
+ const userRoles = userInfo.roles ?? [];
+
+ // 生成菜单和路由
+ const { accessibleMenus, accessibleRoutes } = await generateAccess({
+ roles: userRoles,
+ router,
+ // 则会在菜单中显示,但是访问会被重定向到403
+ routes: accessRoutes,
+ });
+
+ // 保存菜单信息和路由信息
+ accessStore.setAccessMenus(accessibleMenus);
+ accessStore.setAccessRoutes(accessibleRoutes);
+ accessStore.setIsAccessChecked(true);
+ const redirectPath = (from.query.redirect ??
+ (to.path === preferences.app.defaultHomePath
+ ? userInfo.homePath || preferences.app.defaultHomePath
+ : to.fullPath)) as string;
+
+ return {
+ ...router.resolve(decodeURIComponent(redirectPath)),
+ replace: true,
+ };
+ });
+}
+
+/**
+ * WebSocket 守卫配置
+ * @param router
+ */
+export function setupWebSocketGuard(router: Router) {
+ router.beforeEach(async (_) => {
+ const accessStore = useAccessStore();
+ const wsStore = useWebSocketStore();
+
+ // 检查 WebSocket 连接状态
+ if (accessStore.accessToken && !wsStore.isConnected) {
+ wsStore.connect();
+ }
+
+ return true;
+ });
+}
+
+/**
+ * 项目守卫配置
+ * @param router
+ */
+function createRouterGuard(router: Router) {
+ /** 通用 */
+ setupCommonGuard(router);
+ /** 权限访问 */
+ setupAccessGuard(router);
+ /** WebSocket */
+ setupWebSocketGuard(router);
+}
+
+export { createRouterGuard };
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/index.ts
new file mode 100644
index 0000000..4840230
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/index.ts
@@ -0,0 +1,37 @@
+import {
+ createRouter,
+ createWebHashHistory,
+ createWebHistory,
+} from 'vue-router';
+
+import { resetStaticRoutes } from '@vben/utils';
+
+import { createRouterGuard } from './guard';
+import { routes } from './routes';
+
+/**
+ * @zh_CN 创建vue-router实例
+ */
+const router = createRouter({
+ history:
+ import.meta.env.VITE_ROUTER_HISTORY === 'hash'
+ ? createWebHashHistory(import.meta.env.VITE_BASE)
+ : createWebHistory(import.meta.env.VITE_BASE),
+ // 应该添加到路由的初始路由列表。
+ routes,
+ scrollBehavior: (to, _from, savedPosition) => {
+ if (savedPosition) {
+ return savedPosition;
+ }
+ return to.hash ? { behavior: 'smooth', el: to.hash } : { left: 0, top: 0 };
+ },
+ // 是否应该禁止尾部斜杠。
+ // strict: true,
+});
+
+const resetRoutes = () => resetStaticRoutes(router, routes);
+
+// 创建路由守卫
+createRouterGuard(router);
+
+export { resetRoutes, router };
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/core.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/core.ts
new file mode 100644
index 0000000..949b0b6
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/core.ts
@@ -0,0 +1,97 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { LOGIN_PATH } from '@vben/constants';
+import { preferences } from '@vben/preferences';
+
+import { $t } from '#/locales';
+
+const BasicLayout = () => import('#/layouts/basic.vue');
+const AuthPageLayout = () => import('#/layouts/auth.vue');
+/** 全局404页面 */
+const fallbackNotFoundRoute: RouteRecordRaw = {
+ component: () => import('#/views/_core/fallback/not-found.vue'),
+ meta: {
+ hideInBreadcrumb: true,
+ hideInMenu: true,
+ hideInTab: true,
+ title: '404',
+ },
+ name: 'FallbackNotFound',
+ path: '/:path(.*)*',
+};
+
+/** 基本路由,这些路由是必须存在的 */
+const coreRoutes: RouteRecordRaw[] = [
+ /**
+ * 根路由
+ * 使用基础布局,作为所有页面的父级容器,子级就不必配置BasicLayout。
+ * 此路由必须存在,且不应修改
+ */
+ {
+ component: BasicLayout,
+ meta: {
+ hideInBreadcrumb: true,
+ title: 'Root',
+ },
+ name: 'Root',
+ path: '/',
+ redirect: preferences.app.defaultHomePath,
+ children: [],
+ },
+ {
+ component: AuthPageLayout,
+ meta: {
+ hideInTab: true,
+ title: 'Authentication',
+ },
+ name: 'Authentication',
+ path: '/auth',
+ redirect: LOGIN_PATH,
+ children: [
+ {
+ name: 'Login',
+ path: 'login',
+ component: () => import('#/views/_core/authentication/login.vue'),
+ meta: {
+ title: $t('page.auth.login'),
+ },
+ },
+ {
+ name: 'CodeLogin',
+ path: 'code-login',
+ component: () => import('#/views/_core/authentication/code-login.vue'),
+ meta: {
+ title: $t('page.auth.codeLogin'),
+ },
+ },
+ {
+ name: 'QrCodeLogin',
+ path: 'qrcode-login',
+ component: () =>
+ import('#/views/_core/authentication/qrcode-login.vue'),
+ meta: {
+ title: $t('page.auth.qrcodeLogin'),
+ },
+ },
+ {
+ name: 'ForgetPassword',
+ path: 'forget-password',
+ component: () =>
+ import('#/views/_core/authentication/forget-password.vue'),
+ meta: {
+ title: $t('page.auth.forgetPassword'),
+ },
+ },
+ {
+ name: 'Register',
+ path: 'register',
+ component: () => import('#/views/_core/authentication/register.vue'),
+ meta: {
+ title: $t('page.auth.register'),
+ },
+ },
+ ],
+ },
+];
+
+export { coreRoutes, fallbackNotFoundRoute };
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/index.ts
new file mode 100644
index 0000000..931ccd8
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/index.ts
@@ -0,0 +1,55 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { mergeRouteModules, traverseTreeValues } from '@vben/utils';
+
+import { coreRoutes, fallbackNotFoundRoute } from './core';
+
+const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', {
+ eager: true,
+});
+
+const pluginRouteFiles = import.meta.glob('../../plugins/**/routes/*.ts', {
+ eager: true,
+});
+
+// 有需要可以自行打开注释,并创建文件夹
+// const externalRouteFiles = import.meta.glob('./external/**/*.ts', { eager: true });
+// const staticRouteFiles = import.meta.glob('./static/**/*.ts', { eager: true });
+
+/** 动态路由 */
+const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
+
+/** 插件路由 */
+const pluginRoutes: RouteRecordRaw[] = mergeRouteModules(pluginRouteFiles);
+
+/** 外部路由列表,访问这些页面可以不需要Layout,可能用于内嵌在别的系统(不会显示在菜单中) */
+// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
+// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
+const staticRoutes: RouteRecordRaw[] = [];
+const externalRoutes: RouteRecordRaw[] = [];
+
+/** 路由列表,由基本路由、外部路由和404兜底路由组成
+ * 无需走权限验证(会一直显示在菜单中) */
+const routes: RouteRecordRaw[] = [
+ ...coreRoutes,
+ ...externalRoutes,
+ fallbackNotFoundRoute,
+];
+
+/** 基本路由列表,这些路由不需要进入权限拦截 */
+const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
+
+/** 有权限校验的路由列表,包含动态路由和静态路由 */
+const accessRoutes = [...dynamicRoutes, ...pluginRoutes, ...staticRoutes];
+
+const componentKeys: string[] = Object.keys({
+ ...import.meta.glob('../../views/**/*.vue'),
+ ...import.meta.glob('../../plugins/**/*.vue'),
+})
+ .filter((item) => !item.includes('/modules/'))
+ .map((v) => {
+ const path = v.replace('../../views/', '/').replace('../../', '/');
+ return path.endsWith('.vue') ? path.slice(0, -4) : path;
+ });
+
+export { accessRoutes, componentKeys, coreRouteNames, routes };
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/dashboard.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/dashboard.ts
new file mode 100644
index 0000000..5254dc6
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/dashboard.ts
@@ -0,0 +1,38 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+ {
+ meta: {
+ icon: 'lucide:layout-dashboard',
+ order: -1,
+ title: $t('page.dashboard.title'),
+ },
+ name: 'Dashboard',
+ path: '/dashboard',
+ children: [
+ {
+ name: 'Analytics',
+ path: '/analytics',
+ component: () => import('#/views/dashboard/analytics/index.vue'),
+ meta: {
+ affixTab: true,
+ icon: 'lucide:area-chart',
+ title: $t('page.dashboard.analytics'),
+ },
+ },
+ {
+ name: 'Workspace',
+ path: '/workspace',
+ component: () => import('#/views/dashboard/workspace/index.vue'),
+ meta: {
+ icon: 'carbon:workspace',
+ title: $t('page.dashboard.workspace'),
+ },
+ },
+ ],
+ },
+];
+
+export default routes;
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/demos.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/demos.ts
new file mode 100644
index 0000000..4605d91
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/demos.ts
@@ -0,0 +1,28 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+ {
+ meta: {
+ icon: 'ic:baseline-view-in-ar',
+ keepAlive: true,
+ order: 1000,
+ title: $t('demos.title'),
+ },
+ name: 'Demos',
+ path: '/demos',
+ children: [
+ {
+ meta: {
+ title: $t('demos.antd'),
+ },
+ name: 'AntDesignDemos',
+ path: '/demos/ant-design-next',
+ component: () => import('#/views/demos/antd/index.vue'),
+ },
+ ],
+ },
+];
+
+export default routes;
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/log.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/log.ts
new file mode 100644
index 0000000..05756c0
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/log.ts
@@ -0,0 +1,37 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+ {
+ name: 'Log',
+ path: '/log',
+ meta: {
+ title: $t('page.menu.log'),
+ icon: 'carbon:cloud-logging',
+ order: 3,
+ },
+ children: [
+ {
+ name: 'LoginLog',
+ path: '/log/login',
+ component: () => import('#/views/log/login/index.vue'),
+ meta: {
+ title: $t('page.menu.login'),
+ icon: 'mdi:login',
+ },
+ },
+ {
+ name: 'OperaLog',
+ path: '/log/opera',
+ component: () => import('#/views/log/opera/index.vue'),
+ meta: {
+ title: $t('page.menu.opera'),
+ icon: 'carbon:operations-record',
+ },
+ },
+ ],
+ },
+];
+
+export default routes;
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/monitor.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/monitor.ts
new file mode 100644
index 0000000..0f13292
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/monitor.ts
@@ -0,0 +1,46 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+ {
+ name: 'Monitor',
+ path: '/monitor',
+ meta: {
+ title: $t('page.menu.monitor'),
+ icon: 'mdi:monitor-eye',
+ order: 4,
+ },
+ children: [
+ {
+ name: 'Online',
+ path: '/monitor/online',
+ component: () => import('#/views/monitor/online/index.vue'),
+ meta: {
+ title: $t('page.menu.online'),
+ icon: 'wpf:online',
+ },
+ },
+ {
+ name: 'Redis',
+ path: '/monitor/redis',
+ component: () => import('#/views/monitor/redis/index.vue'),
+ meta: {
+ title: $t('page.menu.redis'),
+ icon: 'devicon:redis',
+ },
+ },
+ {
+ name: 'Server',
+ path: '/monitor/server',
+ component: () => import('#/views/monitor/server/index.vue'),
+ meta: {
+ title: $t('page.menu.server'),
+ icon: 'mdi:server-outline',
+ },
+ },
+ ],
+ },
+];
+
+export default routes;
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/scheduler.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/scheduler.ts
new file mode 100644
index 0000000..8de574f
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/scheduler.ts
@@ -0,0 +1,36 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+ {
+ name: 'Scheduler',
+ path: '/scheduler',
+ meta: {
+ title: $t('page.menu.scheduler'),
+ icon: 'ix:scheduler',
+ },
+ children: [
+ {
+ name: 'SchedulerManage',
+ path: '/scheduler/manage',
+ component: () => import('#/views/scheduler/manage/index.vue'),
+ meta: {
+ title: $t('page.menu.schedulerManage'),
+ icon: 'ix:scheduler',
+ },
+ },
+ {
+ name: 'SchedulerRecord',
+ path: '/scheduler/record',
+ component: () => import('#/views/scheduler/record/index.vue'),
+ meta: {
+ title: $t('page.menu.schedulerRecord'),
+ icon: 'ix:scheduler',
+ },
+ },
+ ],
+ },
+];
+
+export default routes;
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/system.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/system.ts
new file mode 100644
index 0000000..dd5e07e
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/system.ts
@@ -0,0 +1,94 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+ {
+ name: 'System',
+ path: '/system',
+ meta: {
+ title: $t('page.menu.system'),
+ icon: 'grommet-icons:system',
+ order: 1,
+ },
+ children: [
+ {
+ name: 'SysDept',
+ path: '/system/dept',
+ component: () => import('#/views/system/dept/index.vue'),
+ meta: {
+ title: $t('page.menu.sysDept'),
+ icon: 'mingcute:department-line',
+ },
+ },
+ {
+ name: 'SysUser',
+ path: '/system/user',
+ component: () => import('#/views/system/user/index.vue'),
+ meta: {
+ title: $t('page.menu.sysUser'),
+ icon: 'ant-design:user-outlined',
+ },
+ },
+ {
+ name: 'SysRole',
+ path: '/system/role',
+ component: () => import('#/views/system/role/index.vue'),
+ meta: {
+ title: $t('page.menu.sysRole'),
+ icon: 'carbon:user-role',
+ },
+ },
+ {
+ name: 'SysMenu',
+ path: '/system/menu',
+ component: () => import('#/views/system/menu/index.vue'),
+ meta: {
+ title: $t('page.menu.sysMenu'),
+ icon: 'material-symbols:menu',
+ },
+ },
+ {
+ name: 'SysDataPermission',
+ path: '/system/data-permission',
+ meta: {
+ title: $t('page.menu.sysDataPermission'),
+ icon: 'icon-park-outline:permissions',
+ },
+ children: [
+ {
+ name: 'SysDataScope',
+ path: '/system/data-scope',
+ component: () =>
+ import('#/views/system/data-permission/scope/index.vue'),
+ meta: {
+ title: $t('page.menu.sysDataScope'),
+ icon: 'cuida:scope-outline',
+ },
+ },
+ {
+ name: 'SysDataRule',
+ path: '/system/data-rule',
+ component: () =>
+ import('#/views/system/data-permission/rule/index.vue'),
+ meta: {
+ title: $t('page.menu.sysDataRule'),
+ icon: 'material-symbols:rule',
+ },
+ },
+ ],
+ },
+ {
+ name: 'SysPlugin',
+ path: '/system/plugin',
+ component: () => import('#/views/system/plugin/index.vue'),
+ meta: {
+ title: $t('page.menu.sysPlugin'),
+ icon: 'clarity:plugin-line',
+ },
+ },
+ ],
+ },
+];
+
+export default routes;
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/vben.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/vben.ts
new file mode 100644
index 0000000..60d3352
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/router/routes/modules/vben.ts
@@ -0,0 +1,72 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { IFrameView } from '#/layouts';
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+ {
+ meta: {
+ badgeType: 'dot',
+ icon: 'https://wu-clan.github.io/picx-images-hosting/logo/fba.png',
+ order: 9998,
+ title: '项目',
+ },
+ name: 'VbenProject',
+ path: '/fba',
+ children: [
+ {
+ name: 'Document',
+ path: '/fba/document',
+ component: IFrameView,
+ meta: {
+ icon: 'lucide:book-open-text',
+ link: 'https://fastapi-practices.github.io/fastapi_best_architecture_docs',
+ title: $t('demos.vben.document'),
+ },
+ },
+ {
+ name: 'Github',
+ path: '/fba/github',
+ component: IFrameView,
+ meta: {
+ icon: 'mdi:github',
+ link: 'https://github.com/fastapi-practices/fastapi_best_architecture',
+ title: 'Github',
+ },
+ },
+ {
+ name: 'Apifox',
+ path: '/fba/apifox',
+ component: IFrameView,
+ meta: {
+ icon: 'simple-icons:apifox',
+ iframeSrc:
+ 'https://apifox.com/apidoc/shared-28a93f02-730b-4f33-bb5e-4dad92058cc0',
+ title: 'Apifox',
+ },
+ },
+ ],
+ },
+ {
+ name: 'VbenAbout',
+ path: '/about',
+ component: () => import('#/views/_core/about/index.vue'),
+ meta: {
+ icon: 'lucide:copyright',
+ title: $t('demos.vben.about'),
+ order: 9999,
+ },
+ },
+ {
+ name: 'Profile',
+ path: '/profile',
+ component: () => import('#/views/_core/profile/index.vue'),
+ meta: {
+ icon: 'lucide:user',
+ hideInMenu: true,
+ title: $t('page.auth.profile'),
+ },
+ },
+];
+
+export default routes;
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/store/auth.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/store/auth.ts
new file mode 100644
index 0000000..1a18268
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/store/auth.ts
@@ -0,0 +1,161 @@
+import type { Recordable } from '@vben/types';
+
+import type { CaptchaResult, LoginParams, MyUserInfo } from '#/api';
+
+import { ref } from 'vue';
+import { useRouter } from 'vue-router';
+
+import { LOGIN_PATH } from '@vben/constants';
+import { preferences } from '@vben/preferences';
+import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
+
+import { notification } from 'antdv-next';
+import { defineStore } from 'pinia';
+
+import {
+ getAccessCodesApi,
+ getCaptchaApi,
+ getUserInfoApi,
+ loginApi,
+ logoutApi,
+} from '#/api';
+import { $t } from '#/locales';
+import { useDictStore, useWebSocketStore } from '#/store';
+
+export const useAuthStore = defineStore('auth', () => {
+ const accessStore = useAccessStore();
+ const userStore = useUserStore();
+ const dictStore = useDictStore();
+ const router = useRouter();
+
+ const loginLoading = ref(false);
+
+ /**
+ * 登陆验证码
+ */
+ async function captcha() {
+ const res: CaptchaResult = await getCaptchaApi();
+ accessStore.setCaptchaUuid(res.uuid);
+ return res;
+ }
+
+ /**
+ * 异步处理登录操作
+ * Asynchronously handle the login process
+ * @param params 登录表单数据
+ */
+ async function authLogin(
+ params: Recordable,
+ onSuccess?: () => Promise | void,
+ ) {
+ // 异步处理用户登录操作并获取 accessToken
+ let userInfo: MyUserInfo | null = null;
+ try {
+ loginLoading.value = true;
+ const { access_token, session_uuid } = await loginApi(
+ params as LoginParams,
+ );
+
+ // 如果成功获取到 accessToken
+ if (access_token) {
+ accessStore.setAccessToken(access_token);
+ accessStore.setAccessSessionUuid(session_uuid);
+
+ // 获取用户信息并存储到 accessStore 中
+ const [fetchUserInfoResult, accessCodes] = await Promise.all([
+ fetchUserInfo(),
+ getAccessCodesApi(),
+ ]);
+
+ userInfo = fetchUserInfoResult;
+
+ userStore.setUserInfo(userInfo);
+ accessStore.setAccessCodes(accessCodes);
+
+ if (accessStore.loginExpired) {
+ accessStore.setLoginExpired(false);
+ } else {
+ onSuccess
+ ? await onSuccess?.()
+ : await router.push(
+ userInfo.homePath || preferences.app.defaultHomePath,
+ );
+ }
+
+ // 初始化WebSocket连接
+ const wsStore = useWebSocketStore();
+ wsStore.connect();
+
+ if (userInfo?.nickname) {
+ notification.success({
+ description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
+ duration: 3,
+ title: $t('authentication.loginSuccess'),
+ });
+ }
+ }
+ } finally {
+ loginLoading.value = false;
+ }
+
+ return {
+ userInfo,
+ };
+ }
+
+ async function oauth2Login() {
+ const params = new URLSearchParams(window.location.search);
+ const access_token = params.get('access_token');
+ const session_uuid = params.get('session_uuid');
+
+ if (access_token && session_uuid) {
+ accessStore.setAccessToken(access_token);
+ accessStore.setAccessSessionUuid(session_uuid);
+ return true;
+ }
+
+ console.error('Missing or invalid access_token or session_uuid');
+ return false;
+ }
+
+ async function logout(redirect: boolean = true) {
+ try {
+ await logoutApi();
+ } catch {
+ // 不做任何处理
+ }
+ resetAllStores();
+ accessStore.setLoginExpired(false);
+
+ // 回登录页带上当前路由地址
+ await router.replace({
+ path: LOGIN_PATH,
+ query: redirect
+ ? {
+ redirect: encodeURIComponent(router.currentRoute.value.fullPath),
+ }
+ : {},
+ });
+ }
+
+ async function fetchUserInfo() {
+ const userInfo = await getUserInfoApi();
+ userStore.setUserInfo(userInfo);
+ dictStore.resetCache();
+ return userInfo;
+ }
+
+ function $reset() {
+ loginLoading.value = false;
+ }
+
+ return {
+ $reset,
+ captcha,
+ authLogin,
+ oauth2Login,
+ fetchUserInfo,
+ loginLoading,
+ logout,
+ };
+});
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/store/dict.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/store/dict.ts
new file mode 100644
index 0000000..a151484
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/store/dict.ts
@@ -0,0 +1,100 @@
+import type { DictDataResult } from '#/plugins/dict/api';
+import type { DictOptionsParams } from '#/utils/dict';
+
+import { reactive } from 'vue';
+
+import { $t } from '@vben/locales';
+
+import { defineStore } from 'pinia';
+
+import { generateDictCacheKey } from '#/utils/dict';
+
+export interface DictOption {
+ disabled?: boolean;
+ label: string;
+ value: boolean | number | string;
+ color?: string;
+}
+
+export function dictToOptions(
+ data: DictDataResult[],
+ params: DictOptionsParams,
+): DictOption[] {
+ const { asBoolean = false, asNumber = false, asString = false } = params;
+
+ return data.map((item) => {
+ let value: boolean | number | string = item.value;
+ if (asBoolean) {
+ value = item.value === 'true';
+ } else if (asNumber) {
+ value = Number(item.value);
+ } else if (asString) {
+ // asString 时保持原样,因为 value 本身就是 string 类型
+ value = item.value;
+ }
+
+ return {
+ disabled: item.status === 0,
+ label: $t(item.label),
+ value,
+ color: item.color,
+ };
+ });
+}
+
+export const useDictStore = defineStore('dict', () => {
+ const dictOptionsMap = reactive(new Map());
+ const dictRequestCache = reactive(
+ new Map>(),
+ );
+
+ function getDictOptions(
+ dictName: string,
+ params: DictOptionsParams,
+ ): DictOption[] {
+ if (!dictName) return [];
+
+ const cacheKey = generateDictCacheKey(dictName, params);
+
+ if (!dictOptionsMap.has(cacheKey)) {
+ dictOptionsMap.set(cacheKey, []);
+ }
+
+ return dictOptionsMap.get(cacheKey) || [];
+ }
+
+ function setDictInfo(
+ dictName: string,
+ dictValue: DictDataResult[],
+ params: DictOptionsParams,
+ ) {
+ const cacheKey = generateDictCacheKey(dictName, params);
+
+ if (
+ dictOptionsMap.has(cacheKey) &&
+ dictOptionsMap.get(cacheKey)?.length === 0
+ ) {
+ dictOptionsMap.get(cacheKey)?.push(...dictToOptions(dictValue, params));
+ } else {
+ dictOptionsMap.set(cacheKey, dictToOptions(dictValue, params));
+ }
+ }
+
+ function resetCache() {
+ dictOptionsMap.clear();
+ dictRequestCache.clear();
+ }
+
+ function $reset() {
+ // doNothing
+ }
+
+ return {
+ $reset,
+ dictOptionsMap,
+ dictRequestCache,
+ getDictOptions,
+ setDictInfo,
+ resetCache,
+ };
+});
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/store/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/store/index.ts
new file mode 100644
index 0000000..369ff35
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/store/index.ts
@@ -0,0 +1,3 @@
+export * from './auth';
+export * from './dict';
+export * from './websocket';
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/store/websocket.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/store/websocket.ts
new file mode 100644
index 0000000..b086e46
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/store/websocket.ts
@@ -0,0 +1,252 @@
+// stores/websocket.ts
+import type { Socket } from 'socket.io-client';
+
+import { computed, ref } from 'vue';
+
+import { useAccessStore } from '@vben/stores';
+
+import { defineStore } from 'pinia';
+import { io } from 'socket.io-client';
+
+export const useWebSocketStore = defineStore('websocket', () => {
+ const socket = ref(null);
+ const isConnected = ref(false);
+ const eventCleanupFunctions = ref void>>([]);
+ const reconnectCallbacks = ref(new Set<() => void>());
+
+ // 连接配置常量
+ const WS_URL = import.meta.env.VITE_GLOB_API_URL;
+ const WS_PATH = '/ws/socket.io';
+
+ // 连接配置参数
+ const WS_CONFIG = {
+ autoConnect: true,
+ path: WS_PATH,
+ reconnection: true,
+ reconnectionAttempts: 3,
+ reconnectionDelay: 1000,
+ transports: ['websocket'],
+ };
+
+ /**
+ * 建立 WebSocket 连接
+ * @returns {boolean} 连接是否成功
+ */
+ const connect = () => {
+ const accessStore = useAccessStore();
+
+ // 检查登录状态
+ if (!accessStore.accessToken) {
+ console.warn('用户未登录或登录已过期,无法建立 WebSocket 连接');
+ return false;
+ }
+
+ // 检查是否已连接
+ if (isConnected.value) {
+ return true;
+ }
+
+ // 如果有 socket 实例但未连接,尝试连接
+ if (socket.value && !isConnected.value) {
+ socket.value.connect();
+ return true;
+ }
+
+ try {
+ // console.log('正在初始化 WebSocket 连接...');
+ // 创建 Socket 连接,携带认证信息
+ socket.value = io(WS_URL, {
+ ...WS_CONFIG,
+ auth: {
+ session_uuid: accessStore.accessSessionUuid,
+ token: accessStore.accessToken,
+ },
+ });
+
+ // 注册核心事件监听器
+ registerCoreEvents();
+ return true;
+ } catch (error) {
+ console.error('WebSocket 初始化失败:', error);
+ return false;
+ }
+ };
+
+ /**
+ * 注册核心事件监听器
+ */
+ const registerCoreEvents = () => {
+ if (!socket.value) return;
+
+ const onConnect = () => {
+ // console.log('WebSocket 连接成功');
+ isConnected.value = true;
+ };
+
+ const onConnectError = (error: Error) => {
+ console.error('WebSocket 连接错误:', error);
+ isConnected.value = false;
+ handleConnectionError();
+ };
+
+ const onDisconnect = (reason: string) => {
+ console.warn('WebSocket 已断开:', reason);
+ isConnected.value = false;
+ };
+
+ socket.value.on('connect', onConnect);
+ socket.value.on('connect_error', onConnectError);
+ socket.value.on('disconnect', onDisconnect);
+
+ // 监听 Manager 的重连事件
+ socket.value.io.on('reconnect_attempt', (attempt: number) => {
+ console.warn(
+ `WebSocket 重连尝试 ${attempt}/${WS_CONFIG.reconnectionAttempts}`,
+ );
+ });
+
+ socket.value.io.on('reconnect', () => {
+ isConnected.value = true;
+ reconnectCallbacks.value.forEach((callback) => callback());
+ });
+
+ socket.value.io.on('reconnect_failed', () => {
+ console.error('WebSocket 重连失败,已达到最大重连次数');
+ isConnected.value = false;
+ });
+
+ // 保存核心事件到清理列表
+ eventCleanupFunctions.value.push(
+ () => socket.value?.off('connect', onConnect),
+ () => socket.value?.off('connect_error', onConnectError),
+ () => socket.value?.off('disconnect', onDisconnect),
+ );
+ };
+
+ /**
+ * 处理连接错误
+ */
+ const handleConnectionError = () => {
+ const accessStore = useAccessStore();
+ if (!accessStore.accessToken) {
+ disconnect();
+ }
+ };
+
+ /**
+ * 发送消息
+ * @param event 事件名称
+ * @param data 发送的数据
+ */
+ const emit = (event: string, data?: any): boolean => {
+ if (!socket.value || !isConnected.value) {
+ console.warn('WebSocket 未连接');
+ return false;
+ }
+
+ try {
+ socket.value.emit(event, data);
+ return true;
+ } catch (error) {
+ console.error('发送消息失败:', error);
+ return false;
+ }
+ };
+
+ /**
+ * 监听事件
+ * @param event 事件名称
+ * @param callback 回调函数
+ */
+ const on = (event: string, callback: (data: any) => void) => {
+ if (!socket.value) {
+ console.warn('WebSocket 未初始化');
+ return () => {};
+ }
+
+ socket.value.on(event, callback);
+
+ // 保存清理函数
+ const cleanup = () => {
+ socket.value?.off(event, callback);
+ };
+
+ eventCleanupFunctions.value.push(cleanup);
+ return cleanup; // 返回清理函数,便于手动清理
+ };
+
+ /**
+ * 监听 WebSocket 重连成功事件
+ * @param callback 重连成功后的回调
+ */
+ const onReconnect = (callback: () => void) => {
+ reconnectCallbacks.value.add(callback);
+
+ return () => {
+ reconnectCallbacks.value.delete(callback);
+ };
+ };
+
+ /**
+ * 移除事件监听
+ * @param event 事件名称
+ * @param callback 可选,特定回调函数
+ */
+ const off = (event: string, callback?: any) => {
+ if (!socket.value) {
+ console.warn('WebSocket 未初始化');
+ return;
+ }
+
+ if (callback) {
+ socket.value.off(event, callback);
+ } else {
+ socket.value.off(event);
+ }
+ };
+
+ /**
+ * 清理所有注册的事件监听
+ */
+ const cleanupEvents = () => {
+ eventCleanupFunctions.value.forEach((cleanup) => cleanup());
+ eventCleanupFunctions.value = [];
+ };
+
+ /**
+ * 断开 WebSocket 连接
+ */
+ const disconnect = () => {
+ if (socket.value) {
+ cleanupEvents();
+ socket.value.disconnect();
+ socket.value = null;
+ isConnected.value = false;
+ }
+ };
+
+ // 连接状态计算属性
+ const connectionStatus = computed(() => {
+ if (!socket.value) return 'disconnected';
+ if (isConnected.value) return 'connected';
+ return socket.value.connected ? 'connected' : 'connecting';
+ });
+
+ function $reset() {
+ disconnect();
+ }
+
+ return {
+ $reset,
+ socket,
+ isConnected,
+ connectionStatus,
+ connect,
+ disconnect,
+ emit,
+ on,
+ onReconnect,
+ off,
+ cleanupEvents,
+ };
+});
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/types/antd.d.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/types/antd.d.ts
new file mode 100644
index 0000000..9c9fd7e
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/types/antd.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/types/index.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/types/index.ts
new file mode 100644
index 0000000..cb72765
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/types/index.ts
@@ -0,0 +1 @@
+export * from './pagination';
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/types/pagination.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/types/pagination.ts
new file mode 100644
index 0000000..fe4f4dd
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/types/pagination.ts
@@ -0,0 +1,10 @@
+interface PaginationResult {
+ items: Array;
+ page: number;
+ size: number;
+ total: number;
+ total_pages: number;
+ links: any;
+}
+
+export type { PaginationResult };
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/utils/dict.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/utils/dict.ts
new file mode 100644
index 0000000..4b58703
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/utils/dict.ts
@@ -0,0 +1,89 @@
+import type { DictDataResult } from '#/plugins/dict/api';
+
+import { getDictDataDetailApi } from '#/plugins/dict/api';
+import { useDictStore } from '#/store';
+
+export enum DictEnum {
+ NOTICE = 'notice',
+ SYS_CHOOSE = 'sys_choose',
+ SYS_DATA_RULE_EXPRESSION = 'sys_data_rule_expression',
+ SYS_DATA_RULE_OPERATOR = 'sys_data_rule_operator',
+ SYS_FRONTEND_CONFIG = 'sys_frontend_config',
+ SYS_LOGIN_STATUS = 'sys_login_status',
+ SYS_MENU_TYPE = 'sys_menu_type',
+ SYS_PLUGIN_TYPE = 'sys_plugin_type',
+ SYS_STATUS = 'sys_status',
+ TASK_PERIOD_TYPE = 'task_period_type',
+ TASK_STRATEGY_TYPE = 'task_strategy_type',
+ USER_ONLINE_STATUS = 'user_online_status',
+}
+
+export const DICT_CONFIG: Record = {
+ [DictEnum.SYS_STATUS]: { asNumber: true },
+ [DictEnum.NOTICE]: { asNumber: true },
+ [DictEnum.SYS_CHOOSE]: { asBoolean: true },
+ [DictEnum.SYS_DATA_RULE_EXPRESSION]: { asNumber: true },
+ [DictEnum.SYS_DATA_RULE_OPERATOR]: { asNumber: true },
+ [DictEnum.SYS_FRONTEND_CONFIG]: { asBoolean: true },
+ [DictEnum.SYS_LOGIN_STATUS]: { asNumber: true },
+ [DictEnum.SYS_MENU_TYPE]: { asNumber: true },
+ [DictEnum.SYS_PLUGIN_TYPE]: { asNumber: true },
+ [DictEnum.TASK_PERIOD_TYPE]: { asString: true },
+ [DictEnum.TASK_STRATEGY_TYPE]: { asNumber: true },
+ [DictEnum.USER_ONLINE_STATUS]: { asNumber: true },
+};
+
+export interface DictOptionsParams {
+ asBoolean?: boolean;
+ asNumber?: boolean;
+ asString?: boolean;
+}
+
+export const generateDictCacheKey = (
+ dictName: string,
+ params: DictOptionsParams = {},
+): string => {
+ const { asBoolean = false, asNumber = false, asString = false } = params;
+ return `${dictName}_${asBoolean}_${asNumber}_${asString}`;
+};
+
+export function getDictOptions(
+ dictName: string,
+ params: DictOptionsParams = {},
+) {
+ const { dictRequestCache, setDictInfo, getDictOptions } = useDictStore();
+ const param =
+ Object.keys(params).length > 0
+ ? (params ?? {})
+ : DICT_CONFIG[dictName] || {};
+ const cacheKey = generateDictCacheKey(dictName, param);
+ const dataList = getDictOptions(dictName, param);
+
+ if (dataList.length === 0 && !dictRequestCache.has(cacheKey)) {
+ const requestPromise = getDictDataDetailApi(dictName)
+ .then((res: DictDataResult[]) => {
+ setDictInfo(dictName, res, param);
+ return res;
+ })
+ .catch((error: any) => {
+ console.error(error);
+ return [] as DictDataResult[];
+ })
+ .finally(() => {
+ if (dataList.length > 0) {
+ dictRequestCache.delete(cacheKey);
+ }
+ });
+
+ dictRequestCache.set(cacheKey, requestPromise);
+ }
+
+ return dataList;
+}
+
+// 预加载所有字典
+export function preloadDictOptions() {
+ Object.keys(DICT_CONFIG).forEach((dictName) => {
+ getDictOptions(dictName, DICT_CONFIG[dictName]);
+ });
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/README.md b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/README.md
new file mode 100644
index 0000000..8248afe
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/README.md
@@ -0,0 +1,3 @@
+# \_core
+
+此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/about/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/about/index.vue
new file mode 100644
index 0000000..0ee5243
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/about/index.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/code-login.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/code-login.vue
new file mode 100644
index 0000000..02370f7
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/code-login.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/forget-password.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/forget-password.vue
new file mode 100644
index 0000000..10444d0
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/forget-password.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/login.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/login.vue
new file mode 100644
index 0000000..f47d78b
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/login.vue
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/qrcode-login.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/qrcode-login.vue
new file mode 100644
index 0000000..23f5f2d
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/qrcode-login.vue
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/register.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/register.vue
new file mode 100644
index 0000000..8c42953
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/authentication/register.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/coming-soon.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/coming-soon.vue
new file mode 100644
index 0000000..f394930
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/coming-soon.vue
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/forbidden.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/forbidden.vue
new file mode 100644
index 0000000..8ea65fe
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/forbidden.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/iframe.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/iframe.vue
new file mode 100644
index 0000000..296a099
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/iframe.vue
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/internal-error.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/internal-error.vue
new file mode 100644
index 0000000..819a47d
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/internal-error.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/not-found.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/not-found.vue
new file mode 100644
index 0000000..4d178e9
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/not-found.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/offline.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/offline.vue
new file mode 100644
index 0000000..5de4a88
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/fallback/offline.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/basic-info.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/basic-info.vue
new file mode 100644
index 0000000..538ccb8
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/basic-info.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+ 点击上传头像
+
+
+
+ {{ userStore.userInfo?.nickname }}
+
+
+
+
+
+
+
{{ userStore.userInfo?.id }}
+
+
+
+
+
+
+
+ {{ userStore.userInfo?.dept }}
+
+
+ 未绑定
+
+
+
+
+
+
+
+ 最后登录时间:{{ userStore.userInfo?.last_login_time }}
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/binding.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/binding.vue
new file mode 100644
index 0000000..cefb0fd
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/binding.vue
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.source }}
+
+ {{ item.statusString }}
+
+
+
{{ item.description }}
+
+
+
+ 绑定
+
+
+ 解绑
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/data.ts
new file mode 100644
index 0000000..d14f43b
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/data.ts
@@ -0,0 +1,204 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
+import type { OnlineMonitorResult } from '#/api';
+
+import { ref } from 'vue';
+
+import { $t } from '@vben/locales';
+
+import { message } from 'antdv-next';
+
+import { z } from '#/adapter/form';
+import { getPhoneCaptchaApi } from '#/plugins/aliyun_sms/api';
+import { getEmailCaptchaApi } from '#/plugins/email/api';
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+export const avatarSchema: VbenFormSchema[] = [
+ {
+ component: 'Textarea',
+ fieldName: 'avatar',
+ label: '头像链接',
+ rules: 'required',
+ },
+];
+
+export const nicknameSchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'nickname',
+ label: '昵称',
+ rules: 'required',
+ },
+];
+
+const CODE_LENGTH = 6;
+const phoneValue = ref('');
+export const phoneSchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'phone',
+ label: '手机号',
+ rules: z
+ .string()
+ .min(1, { message: $t('authentication.mobileTip') })
+ .refine((v) => /^\d{11}$/.test(v), {
+ message: $t('authentication.mobileErrortip'),
+ }),
+ },
+ {
+ component: 'VbenPinInput',
+ componentProps: {
+ codeLength: CODE_LENGTH,
+ createText: (countdown: number) => {
+ return countdown > 0
+ ? $t('authentication.sendText', [countdown])
+ : $t('authentication.sendCode');
+ },
+ handleSendCode: async () => {
+ try {
+ await getPhoneCaptchaApi({ phone: phoneValue.value });
+ message.success('短信验证码已发送,请注意查收');
+ } catch (error) {
+ console.error(error);
+ }
+ },
+ placeholder: $t('authentication.code'),
+ },
+ dependencies: {
+ trigger(values) {
+ phoneValue.value = values.phone;
+ },
+ triggerFields: ['phone'],
+ },
+ fieldName: 'captcha',
+ label: '验证码',
+ rules: z.string().length(CODE_LENGTH, {
+ message: $t('authentication.codeTip', [CODE_LENGTH]),
+ }),
+ },
+];
+
+const emailValue = ref('');
+export const emailSchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'email',
+ label: '邮箱',
+ rules: z.string().email({ message: '无效的邮箱地址' }),
+ },
+ {
+ component: 'VbenPinInput',
+ componentProps: {
+ codeLength: CODE_LENGTH,
+ createText: (countdown: number) => {
+ return countdown > 0
+ ? $t('authentication.sendText', [countdown])
+ : $t('authentication.sendCode');
+ },
+ handleSendCode: async () => {
+ try {
+ await getEmailCaptchaApi({ recipients: emailValue.value });
+ message.success('邮箱验证码已发送,请注意查收');
+ } catch (error) {
+ console.error(error);
+ }
+ },
+ placeholder: $t('authentication.code'),
+ },
+ dependencies: {
+ trigger(values) {
+ emailValue.value = values.email;
+ },
+ triggerFields: ['email'],
+ },
+ fieldName: 'captcha',
+ label: '验证码',
+ rules: z.string().length(CODE_LENGTH, {
+ message: $t('authentication.codeTip', [CODE_LENGTH]),
+ }),
+ },
+];
+
+export const passwordSchema: VbenFormSchema[] = [
+ {
+ component: 'InputPassword',
+ fieldName: 'old_password',
+ label: '当前密码',
+ rules: z
+ .string({ message: '请输入当前密码' })
+ .min(6, '密码长度不能少于 6 个字符')
+ .max(20, '密码长度不能超过 20 个字符'),
+ },
+ {
+ component: 'InputPassword',
+ fieldName: 'new_password',
+ label: '新密码',
+ rules: z
+ .string({ message: '请输入新密码' })
+ .min(6, '密码长度不能少于 6 个字符')
+ .max(20, '密码长度不能超过 20 个字符'),
+ },
+ {
+ component: 'InputPassword',
+ fieldName: 'confirm_password',
+ label: '确认密码',
+ dependencies: {
+ rules(values) {
+ return z
+ .string({ message: '请输入确认密码' })
+ .min(6, '密码长度不能少于 6 个字符')
+ .max(20, '密码长度不能超过 20 个字符')
+ .refine(
+ (value) => value === values.new_password,
+ '两次密码输出不一致',
+ );
+ },
+ triggerFields: ['new_password', 'confirm_password'],
+ },
+ },
+];
+
+export function useColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ { field: 'ip', title: 'IP 地址' },
+ { field: 'os', title: '操作系统' },
+ { field: 'browser', title: '浏览器' },
+ { field: 'device', title: '设备' },
+ {
+ field: 'status',
+ title: '状态',
+ cellRender: {
+ name: 'CellTag',
+ // options: [
+ // { color: 'success', label: '在线', value: 1 },
+ // { color: 'warning', label: '离线', value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.USER_ONLINE_STATUS),
+ },
+ },
+ { field: 'last_login_time', title: '最后登录时间' },
+ { field: 'expire_time', title: '过期时间' },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 120,
+ cellRender: {
+ attrs: {
+ nameField: 'nickname',
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: [
+ {
+ code: 'delete',
+ text: '强制下线',
+ },
+ ],
+ },
+ },
+ ];
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/index.vue
new file mode 100644
index 0000000..542d026
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/index.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+ onTabChange(key)"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/online-device.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/online-device.vue
new file mode 100644
index 0000000..dc5a613
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/online-device.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+ 我的已登录设备
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/security.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/security.vue
new file mode 100644
index 0000000..133e28b
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/_core/profile/security.vue
@@ -0,0 +1,213 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.title }}
+
+ {{ item.statusString }}
+
+
+
{{ item.description }}
+
+
+
+ {{ item.status ? '修改' : '绑定' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-trends.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-trends.vue
new file mode 100644
index 0000000..f1f0b23
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-trends.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-visits-data.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-visits-data.vue
new file mode 100644
index 0000000..190fb41
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-visits-data.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-visits-sales.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-visits-sales.vue
new file mode 100644
index 0000000..6ff5208
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-visits-sales.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-visits-source.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-visits-source.vue
new file mode 100644
index 0000000..0915c7a
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-visits-source.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-visits.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-visits.vue
new file mode 100644
index 0000000..7e0f101
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/analytics-visits.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/index.vue
new file mode 100644
index 0000000..e794c99
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/analytics/index.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/workspace/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/workspace/index.vue
new file mode 100644
index 0000000..b95d613
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/dashboard/workspace/index.vue
@@ -0,0 +1,266 @@
+
+
+
+
+
+
+ 早安, {{ userStore.userInfo?.realName }}, 开始您一天的工作吧!
+
+ 今日晴,20℃ - 32℃!
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/demos/antd/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/demos/antd/index.vue
new file mode 100644
index 0000000..6fb1998
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/demos/antd/index.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/log/login/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/log/login/data.ts
new file mode 100644
index 0000000..1fea5a3
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/log/login/data.ts
@@ -0,0 +1,74 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeGridProps } from '#/adapter/vxe-table';
+
+import { $t } from '@vben/locales';
+
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+export const querySchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'username',
+ label: '用户名',
+ },
+ {
+ component: 'Input',
+ fieldName: 'ip',
+ label: 'IP 地址',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ // options: [
+ // {
+ // label: '成功',
+ // value: 1,
+ // },
+ // {
+ // label: '失败',
+ // value: 0,
+ // },
+ // ],
+ options: getDictOptions(DictEnum.SYS_LOGIN_STATUS),
+ },
+ fieldName: 'status',
+ label: $t('common.form.status'),
+ },
+];
+
+export const columns: VxeGridProps['columns'] = [
+ { field: 'checkbox', type: 'checkbox', align: 'left', width: 50 },
+ {
+ field: 'seq',
+ title: $t('common.table.id'),
+ type: 'seq',
+ width: 50,
+ },
+ { field: 'username', title: '用户名' },
+ {
+ field: 'status',
+ title: '状态',
+ cellRender: {
+ name: 'CellTag',
+ // options: [
+ // { color: 'success', label: '成功', value: 1 },
+ // { color: 'error', label: '失败', value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_LOGIN_STATUS),
+ },
+ },
+ { field: 'ip', title: 'IP 地址' },
+ { field: 'country', title: '国家' },
+ { field: 'region', title: '地区' },
+ { field: 'os', title: '操作系统' },
+ { field: 'browser', title: '浏览器' },
+ { field: 'device', title: '设备' },
+ { field: 'msg', title: '消息', width: 150 },
+ { field: 'login_time', title: '登录时间', width: 168 },
+ {
+ field: 'created_time',
+ title: $t('common.table.created_time'),
+ width: 168,
+ },
+];
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/log/login/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/log/login/index.vue
new file mode 100644
index 0000000..fbb05dd
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/log/login/index.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+ 删除日志
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/log/opera/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/log/opera/data.ts
new file mode 100644
index 0000000..129dea8
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/log/opera/data.ts
@@ -0,0 +1,102 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
+import type { OperaLogResult } from '#/api';
+
+import { $t } from '@vben/locales';
+
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+export const querySchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'username',
+ label: '用户名',
+ },
+ {
+ component: 'Input',
+ fieldName: 'ip',
+ label: 'IP 地址',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ // options: [
+ // {
+ // label: '正常',
+ // value: 1,
+ // },
+ // {
+ // label: '异常',
+ // value: 0,
+ // },
+ // ],
+ options: getDictOptions(DictEnum.SYS_LOGIN_STATUS),
+ },
+ fieldName: 'status',
+ label: $t('common.form.status'),
+ },
+];
+
+export function useColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ { field: 'checkbox', type: 'checkbox', align: 'left', width: 50 },
+ {
+ field: 'seq',
+ title: $t('common.table.id'),
+ type: 'seq',
+ width: 50,
+ },
+ { field: 'username', title: '用户名' },
+ {
+ field: 'method',
+ title: '请求方法',
+ cellRender: {
+ name: 'CellTag',
+ options: [
+ { color: 'processing', label: 'GET', value: 'GET' },
+ { color: 'success', label: 'POST', value: 'POST' },
+ { color: 'cyan', label: 'PUT', value: 'PUT' },
+ { color: 'error', label: 'PUT', value: 'DELETE' },
+ ],
+ },
+ },
+ { field: 'title', title: '操作标题', align: 'left', width: 200 },
+ { field: 'path', title: '请求路径', align: 'left', width: 250 },
+ {
+ field: 'status',
+ title: '状态',
+ cellRender: {
+ name: 'CellTag',
+ // options: [
+ // { color: 'success', label: '成功', value: 1 },
+ // { color: 'error', label: '失败', value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_LOGIN_STATUS),
+ },
+ },
+ { field: 'cost_time', title: '耗时(ms)' },
+ { field: 'opera_time', title: '操作时间' },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 100,
+ cellRender: {
+ attrs: {
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: [
+ {
+ code: 'details',
+ text: '详情',
+ },
+ ],
+ },
+ },
+ ];
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/log/opera/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/log/opera/index.vue
new file mode 100644
index 0000000..b8bb96b
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/log/opera/index.vue
@@ -0,0 +1,228 @@
+
+
+
+
+
+
+
+
+ 删除日志
+
+
+
+
+
+
+
+
+ 成功
+
+ 失败
+
+
+
+ {{ operaLogDetails?.cost_time }} ms
+
+
+ {{ operaLogDetails?.cost_time }} ms
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/online/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/online/data.ts
new file mode 100644
index 0000000..132251b
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/online/data.ts
@@ -0,0 +1,69 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
+import type { OnlineMonitorResult } from '#/api';
+
+import { $t } from '@vben/locales';
+
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+export const querySchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'username',
+ label: '用户名',
+ },
+];
+
+export function useColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ {
+ field: 'seq',
+ title: $t('common.table.id'),
+ type: 'seq',
+ width: 50,
+ },
+ { field: 'session_uuid', title: '会话 UUID', width: 280 },
+ { field: 'username', title: '用户名' },
+ { field: 'nickname', title: '昵称' },
+ { field: 'ip', title: 'IP 地址' },
+ { field: 'os', title: '操作系统' },
+ { field: 'browser', title: '浏览器' },
+ { field: 'device', title: '设备' },
+ {
+ field: 'status',
+ title: '状态',
+ cellRender: {
+ name: 'CellTag',
+ // options: [
+ // { color: 'success', label: '在线', value: 1 },
+ // { color: 'warning', label: '离线', value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.USER_ONLINE_STATUS),
+ },
+ },
+ { field: 'last_login_time', title: '最后登录时间' },
+ { field: 'expire_time', title: '过期时间' },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 130,
+ cellRender: {
+ attrs: {
+ nameField: 'nickname',
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: [
+ {
+ code: 'delete',
+ text: '强制下线',
+ },
+ ],
+ },
+ },
+ ];
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/online/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/online/index.vue
new file mode 100644
index 0000000..8354792
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/online/index.vue
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+ 当前已登录人数:
+ {{ onlineCount }}
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/redis/components/active-series.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/redis/components/active-series.vue
new file mode 100644
index 0000000..c82c02e
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/redis/components/active-series.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/redis/components/commands-series.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/redis/components/commands-series.vue
new file mode 100644
index 0000000..bb7e8a1
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/redis/components/commands-series.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/redis/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/redis/index.vue
new file mode 100644
index 0000000..d98df01
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/redis/index.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/server/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/server/index.vue
new file mode 100644
index 0000000..5d7fbea
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/monitor/server/index.vue
@@ -0,0 +1,213 @@
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/manage/cron-builder.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/manage/cron-builder.vue
new file mode 100644
index 0000000..6a84130
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/manage/cron-builder.vue
@@ -0,0 +1,552 @@
+
+
+
+
+
+
+
快捷预设
+
+
+ {{ preset.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ WEEKDAY_LABELS[v - 1] }}
+
+
+
+
+
+
+
+
+
+ 近五次执行时间
+
+
+
+ {{ dayjs(time).format('YYYY-MM-DD HH:mm:ss') }}
+
+
+
+ 无法计算运行时间
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/manage/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/manage/data.ts
new file mode 100644
index 0000000..b83dfd7
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/manage/data.ts
@@ -0,0 +1,388 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
+import type { TaskSchedulerResult } from '#/api';
+
+import { h } from 'vue';
+
+import { $t } from '@vben/locales';
+
+import { z } from '#/adapter/form';
+import { getTaskRegisteredApi } from '#/api';
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+export const CRONTAB_PRESETS = [
+ { label: '每分钟', value: '* * * * *' },
+ { label: '每小时整点', value: '0 * * * *' },
+ { label: '每天午夜', value: '0 0 * * *' },
+ { label: '每天 9:00', value: '0 9 * * *' },
+ { label: '工作日 9:00', value: '0 9 * * 1-5' },
+ { label: '每15分钟', value: '*/15 * * * *' },
+ { label: '每3小时', value: '0 */3 * * *' },
+];
+
+export const querySchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '任务名称',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DictEnum.TASK_STRATEGY_TYPE),
+ },
+ fieldName: 'type',
+ label: '策略类型',
+ },
+];
+
+export function useColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ {
+ field: 'seq',
+ title: $t('common.table.id'),
+ type: 'seq',
+ width: 50,
+ },
+ {
+ field: 'name',
+ title: '任务名称',
+ minWidth: 120,
+ },
+ {
+ field: 'task',
+ title: 'Celery 任务',
+ minWidth: 200,
+ showOverflow: true,
+ },
+ {
+ field: 'type',
+ title: '策略类型',
+ width: 150,
+ cellRender: {
+ name: 'CellTag',
+ options: getDictOptions(DictEnum.TASK_STRATEGY_TYPE),
+ },
+ },
+ {
+ field: 'schedule',
+ title: '触发策略',
+ minWidth: 150,
+ slots: { default: 'schedule' },
+ titleSuffix: {
+ icon: 'vxe-icon-question-circle-fill',
+ content:
+ 'Crontab 表达式:https://docs.celeryq.dev/en/latest/userguide/periodic-tasks.html#crontab-schedules',
+ },
+ },
+ {
+ field: 'enabled',
+ title: '状态',
+ width: 100,
+ slots: { default: 'enabled' },
+ },
+ {
+ field: 'total_run_count',
+ title: '执行总计',
+ width: 100,
+ slots: { default: 'total_run_count' },
+ },
+ {
+ field: 'last_run_time',
+ title: '最近执行',
+ width: 168,
+ },
+ {
+ field: 'execute',
+ title: '手动执行',
+ align: 'center',
+ fixed: 'right',
+ width: 80,
+ slots: { default: 'execute' },
+ },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 160,
+ cellRender: {
+ attrs: {
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: [
+ {
+ code: 'log',
+ text: '日志',
+ },
+ 'edit',
+ 'delete',
+ ],
+ },
+ },
+ ];
+}
+
+export function createSchema(
+ onOpenCrontabBuilder: () => void,
+): VbenFormSchema[] {
+ return [
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: 'Interval(间隔)', value: 0 },
+ // { label: 'Crontab(计划)', value: 1 },
+ // ],
+ options: getDictOptions(DictEnum.TASK_STRATEGY_TYPE),
+ optionType: 'button',
+ },
+ defaultValue: 1,
+ fieldName: 'type',
+ formItemClass: 'col-span-2 md:col-span-2',
+ label: '策略类型',
+ },
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '任务名称',
+ rules: 'required',
+ },
+ {
+ component: 'ApiSelect',
+ componentProps: {
+ allowClear: true,
+ api: getTaskRegisteredApi,
+ class: 'w-full',
+ labelField: 'name',
+ valueField: 'task',
+ placeholder: 'Celery 任务名称或任务模块化字符串',
+ },
+ fieldName: 'task',
+ label: 'Celery 任务',
+ rules: 'required',
+ },
+ {
+ component: 'Textarea',
+ fieldName: 'args',
+ label: '位置参数',
+ help: 'JSON 数组格式,例如:[1, "hello", true]',
+ rules: z
+ .string()
+ .optional()
+ .transform((val, ctx) => {
+ if (!val || val.trim() === '') return undefined;
+ try {
+ const parsed = JSON.parse(val);
+ if (!Array.isArray(parsed)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: '必须是 JSON 数组格式,例如:[1, "hello", true]',
+ });
+ return z.NEVER;
+ }
+ return JSON.stringify(parsed);
+ } catch {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: '无效的 JSON 格式,请输入合法的 JSON 数组',
+ });
+ return z.NEVER;
+ }
+ }),
+ },
+ {
+ component: 'Textarea',
+ fieldName: 'kwargs',
+ label: '关键字参数',
+ help: 'JSON 对象格式,例如:{"key": "value", "count": 10}',
+ rules: z
+ .string()
+ .optional()
+ .transform((val, ctx) => {
+ if (!val || val.trim() === '') return undefined;
+ try {
+ const parsed = JSON.parse(val);
+ if (
+ Array.isArray(parsed) ||
+ typeof parsed !== 'object' ||
+ parsed === null
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: '必须是 JSON 对象格式,例如:{"key": "value"}',
+ });
+ return z.NEVER;
+ }
+ return JSON.stringify(parsed);
+ } catch {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: '无效的 JSON 格式,请输入合法的 JSON 对象',
+ });
+ return z.NEVER;
+ }
+ }),
+ },
+ {
+ component: 'Input',
+ fieldName: 'queue',
+ label: '执行队列',
+ help: '将任务下发到指定队列',
+ },
+ {
+ component: 'Input',
+ fieldName: 'exchange',
+ label: '消息交换机',
+ help: '参考:https://docs.celeryq.dev/en/stable/userguide/routing.html#exchanges-queues-and-routing-keys',
+ },
+ {
+ component: 'Input',
+ fieldName: 'routing_key',
+ label: '路由密钥',
+ help: '参考:https://docs.celeryq.dev/en/stable/userguide/routing.html#exchanges-queues-and-routing-keys',
+ },
+ {
+ component: 'DatePicker',
+ componentProps: {
+ class: 'w-full',
+ showTime: true,
+ },
+ fieldName: 'start_time',
+ label: '开始执行时间',
+ },
+ {
+ component: 'DatePicker',
+ componentProps: {
+ class: 'w-full',
+ showTime: true,
+ },
+ dependencies: {
+ disabled: (values) => {
+ return !!values.expire_seconds;
+ },
+ triggerFields: ['expire_seconds'],
+ },
+ fieldName: 'expire_time',
+ label: '过期时间',
+ help: '如果任务执行到该时间没有执行完,则取消执行',
+ },
+ {
+ component: 'InputNumber',
+ componentProps: {
+ class: 'w-full',
+ },
+ dependencies: {
+ disabled: (values) => {
+ return !!values.expire_time;
+ },
+ triggerFields: ['expire_time'],
+ },
+ fieldName: 'expire_seconds',
+ label: '过期秒数',
+ help: '如果任务执行超过该秒后没有执行完,则取消执行',
+ },
+ {
+ component: 'InputNumber',
+ componentProps: {
+ class: 'w-full',
+ min: 1,
+ },
+ dependencies: {
+ show: (values) => {
+ return values.type === 0;
+ },
+ required: (values) => {
+ return values.type === 0;
+ },
+ triggerFields: ['type'],
+ },
+ fieldName: 'interval_every',
+ label: '执行周期',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ class: 'w-full',
+ options: getDictOptions(DictEnum.TASK_PERIOD_TYPE),
+ },
+ dependencies: {
+ show: (values) => {
+ return values.type === 0;
+ },
+ triggerFields: ['type'],
+ },
+ defaultValue: 'seconds',
+ fieldName: 'interval_period',
+ label: '周期类型',
+ },
+ {
+ component: 'Input',
+ dependencies: {
+ show: (values) => {
+ return values.type === 1;
+ },
+ required: (values) => {
+ return values.type === 1;
+ },
+ triggerFields: ['type'],
+ },
+ fieldName: 'crontab',
+ label: '执行计划',
+ rules: z
+ .string()
+ .optional()
+ .refine(
+ (val) =>
+ !val ||
+ val.trim() === '' ||
+ /^(?:[\d*/,-]+\s+){4}[\d*/,-]+$/.test(val.trim()),
+ {
+ message: '无效的 Crontab 表达式',
+ },
+ ),
+ help: 'Crontab 表达式:https://docs.celeryq.dev/en/latest/userguide/periodic-tasks.html#crontab-schedules',
+ renderComponentContent: () => ({
+ suffix: () =>
+ h(
+ 'span',
+ {
+ style: 'cursor: default;',
+ title: '可视化配置',
+ onMousedown: (e: Event) => e.preventDefault(),
+ onClick: (e: Event) => {
+ e.stopPropagation();
+ onOpenCrontabBuilder();
+ },
+ },
+ '⚙',
+ ),
+ }),
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: $t('common.enabled'), value: true },
+ // { label: $t('common.disabled'), value: false },
+ // ],
+ options: getDictOptions(DictEnum.SYS_CHOOSE),
+ optionType: 'button',
+ },
+ defaultValue: false,
+ fieldName: 'one_off',
+ label: '只执行一次',
+ },
+ {
+ component: 'Textarea',
+ fieldName: 'remark',
+ label: '备注',
+ },
+ ];
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/manage/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/manage/index.vue
new file mode 100644
index 0000000..2f7224d
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/manage/index.vue
@@ -0,0 +1,326 @@
+
+
+
+
+
+
+ modalApi.setData(null).open()">
+
+ 创建任务
+
+
+
+
+
+
+
接下来 5 次运行时间:
+
+
+ {{ dayjs(time).format('YYYY-MM-DD HH:mm:ss') }}
+
+
+
无法计算运行时间
+
+
+
+ {{ getScheduleLabel(row) }}
+
+
+
+
+
+
+
+ {{ row.total_run_count }} 次
+
+
+
+ 执行
+
+
+ 执行
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/manage/schedule.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/manage/schedule.ts
new file mode 100644
index 0000000..32b4d4e
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/manage/schedule.ts
@@ -0,0 +1,50 @@
+import type { TaskSchedulerResult } from '#/api';
+
+import { Cron } from 'croner';
+
+const periodLabelMap: Record = {
+ days: '天',
+ hours: '小时',
+ minutes: '分钟',
+ seconds: '秒',
+};
+
+const periodToSeconds: Record = {
+ days: 86_400,
+ hours: 3600,
+ minutes: 60,
+ seconds: 1,
+};
+
+function getScheduleLabel(row: TaskSchedulerResult): string {
+ if (row.type === 0) {
+ const label =
+ periodLabelMap[row.interval_period || 'seconds'] || row.interval_period;
+ return `每 ${row.interval_every || '?'} ${label}`;
+ }
+ return row.crontab || '';
+}
+
+function getNextRuns(row: TaskSchedulerResult): Date[] {
+ try {
+ if (row.type === 0) {
+ const every = row.interval_every;
+ const period = row.interval_period || 'seconds';
+ if (!every || every <= 0) return [];
+ const intervalMs = every * (periodToSeconds[period] || 1) * 1000;
+ const baseTime = row.last_run_time
+ ? new Date(row.last_run_time)
+ : new Date();
+ return Array.from(
+ { length: 5 },
+ (_, i) => new Date(baseTime.getTime() + intervalMs * (i + 1)),
+ );
+ }
+ if (!row.crontab) return [];
+ return new Cron(row.crontab).nextRuns(5);
+ } catch {
+ return [];
+ }
+}
+
+export { getNextRuns, getScheduleLabel };
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/record/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/record/data.ts
new file mode 100644
index 0000000..10efd17
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/record/data.ts
@@ -0,0 +1,74 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
+import type { TaskResult } from '#/api';
+
+import { $t } from '@vben/locales';
+
+export const querySchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'task_id',
+ label: '任务 ID',
+ },
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '任务名称',
+ },
+];
+
+export function useColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ { field: 'checkbox', type: 'checkbox', align: 'left', width: 50 },
+ {
+ field: 'task_id',
+ title: '任务 ID',
+ minWidth: 200,
+ showOverflow: true,
+ },
+ { field: 'name', title: '任务名称' },
+ {
+ field: 'status',
+ title: '状态',
+ cellRender: {
+ name: 'CellTag',
+ options: [
+ { color: 'success', label: 'SUCCESS', value: 'SUCCESS' },
+ { color: 'error', label: 'FAILURE', value: 'FAILURE' },
+ { color: 'processing', label: 'PENDING', value: 'PENDING' },
+ { color: 'processing', label: 'STARTED', value: 'STARTED' },
+ { color: 'warning', label: 'RETRY', value: 'RETRY' },
+ { color: 'default', label: 'REVOKED', value: 'REVOKED' },
+ ],
+ },
+ },
+ {
+ field: 'result',
+ title: '结果',
+ showOverflow: true,
+ },
+ { field: 'retries', title: '重试次数' },
+ { field: 'date_done', title: '结束时间', width: 168 },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 100,
+ cellRender: {
+ attrs: {
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: [
+ {
+ code: 'details',
+ text: '详情',
+ },
+ ],
+ },
+ },
+ ];
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/record/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/record/index.vue
new file mode 100644
index 0000000..f115164
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/scheduler/record/index.vue
@@ -0,0 +1,281 @@
+
+
+
+
+
+
+
+
+ 删除记录
+
+
+
+
+
+
+
+
+ SUCCESS
+
+
+ FAILURE
+
+
+ {{ taskResultDetails?.status }}
+
+
+ RETRY
+
+
+ REVOKED
+
+
+ {{ taskResultDetails?.status }}
+
+
+
+
+ 无
+
+
+
+ 无
+
+
+
+ {{ taskResultDetails?.result }}
+
+ 无
+
+
+
+ {{ taskResultDetails?.traceback }}
+
+ 无
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/data-permission/rule/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/data-permission/rule/data.ts
new file mode 100644
index 0000000..55116c7
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/data-permission/rule/data.ts
@@ -0,0 +1,186 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
+import type { SysDataRuleResult } from '#/api';
+
+import { $t } from '@vben/locales';
+
+import { getSysDataRuleModelColumnsApi } from '#/api';
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+export const querySchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '数据规则名称',
+ },
+];
+
+export function useColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ {
+ field: 'seq',
+ title: $t('common.table.id'),
+ type: 'seq',
+ width: 50,
+ },
+ { field: 'name', title: '规则名称' },
+ { field: 'model', title: '模型名称' },
+ { field: 'column', title: '列名称' },
+ {
+ field: 'operator',
+ title: '操作符',
+ cellRender: {
+ name: 'CellTag',
+ // options: [
+ // { color: 'cyan', label: 'OR', value: 1 },
+ // { color: 'success', label: 'AND', value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_DATA_RULE_OPERATOR),
+ },
+ width: 80,
+ },
+ {
+ field: 'expression',
+ title: '表达式',
+ cellRender: {
+ name: 'CellTag',
+ // options: [
+ // { label: 'not in', value: 7 },
+ // { label: 'in', value: 6 },
+ // { label: '<=', value: 5 },
+ // { label: '<', value: 4 },
+ // { label: '>=', value: 3 },
+ // { label: '>', value: 2 },
+ // { label: '!=', value: 1 },
+ // { label: '==', value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_DATA_RULE_EXPRESSION),
+ },
+ width: 80,
+ },
+ { field: 'value', title: '规则值' },
+ {
+ field: 'created_time',
+ title: $t('common.table.created_time'),
+ width: 168,
+ },
+ {
+ field: 'updated_time',
+ title: $t('common.table.updated_time'),
+ width: 168,
+ },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 120,
+ cellRender: {
+ attrs: {
+ nameField: 'name',
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ },
+ },
+ ];
+}
+
+export const schema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '规则名称',
+ rules: 'required',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ class: 'w-full',
+ options: [],
+ },
+ fieldName: 'model',
+ label: '模型',
+ rules: 'selectRequired',
+ },
+ {
+ component: 'Select',
+ fieldName: 'column',
+ label: '字段',
+ rules: 'selectRequired',
+ dependencies: {
+ componentProps: async (values) => {
+ if (values.model) {
+ const res = await getSysDataRuleModelColumnsApi(values.model);
+ return {
+ class: 'w-full',
+ options: res.map((item) => ({
+ label: item.comment,
+ value: item.key,
+ })),
+ };
+ }
+ return { class: 'w-full' };
+ },
+ disabled(values) {
+ return !values.model;
+ },
+ triggerFields: ['model'],
+ },
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: 'OR', value: 1 },
+ // { label: 'AND', value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_DATA_RULE_OPERATOR),
+ optionType: 'button',
+ },
+ defaultValue: 0,
+ fieldName: 'operator',
+ label: '操作符',
+ rules: 'required',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ class: 'w-full',
+ // options: [
+ // { label: 'not in', value: 7 },
+ // { label: 'in', value: 6 },
+ // { label: '<=', value: 5 },
+ // { label: '<', value: 4 },
+ // { label: '>=', value: 3 },
+ // { label: '>', value: 2 },
+ // { label: '!=', value: 1 },
+ // { label: '==', value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_DATA_RULE_EXPRESSION),
+ },
+ fieldName: 'expression',
+ label: '表达式',
+ rules: 'selectRequired',
+ },
+ {
+ component: 'AutoComplete',
+ componentProps: {
+ placeholder: '请输入值或选择参数',
+ allowClear: true,
+ class: 'w-full',
+ showSearch: {
+ filterOption: (input: string, option: any) =>
+ (option?.value || '').toLowerCase().includes(input.toLowerCase()) ||
+ (option?.label || '').toLowerCase().includes(input.toLowerCase()),
+ },
+ options: [],
+ },
+ fieldName: 'value',
+ label: '规则值',
+ rules: 'required',
+ },
+];
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/data-permission/rule/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/data-permission/rule/index.vue
new file mode 100644
index 0000000..f4ee401
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/data-permission/rule/index.vue
@@ -0,0 +1,203 @@
+
+
+
+
+
+
+
+
+ 新增数据规则
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/data-permission/scope/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/data-permission/scope/data.ts
new file mode 100644
index 0000000..16b0bbf
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/data-permission/scope/data.ts
@@ -0,0 +1,121 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
+import type { SysDataScopeResult } from '#/api';
+
+import { $t } from '@vben/locales';
+
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+export const querySchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '数据范围名称',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ // options: [
+ // {
+ // label: '正常',
+ // value: 1,
+ // },
+ // {
+ // label: '停用',
+ // value: 0,
+ // },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS),
+ },
+ fieldName: 'status',
+ label: $t('common.form.status'),
+ },
+];
+
+export function useColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ {
+ field: 'seq',
+ title: $t('common.table.id'),
+ type: 'seq',
+ width: 50,
+ },
+ { field: 'name', title: '范围名称' },
+ {
+ field: 'status',
+ title: '状态',
+ cellRender: {
+ name: 'CellTag',
+ },
+ width: 100,
+ },
+ {
+ field: 'created_time',
+ title: $t('common.table.created_time'),
+ width: 168,
+ },
+ {
+ field: 'updated_time',
+ title: $t('common.table.updated_time'),
+ width: 168,
+ },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 200,
+ cellRender: {
+ attrs: {
+ nameField: 'name',
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: [
+ {
+ code: 'rule',
+ text: '规则设置',
+ },
+ 'edit',
+ 'delete',
+ ],
+ },
+ },
+ ];
+}
+
+export const schema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '数据范围名称',
+ rules: 'required',
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: $t('common.enabled'), value: 1 },
+ // { label: $t('common.disabled'), value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS),
+ optionType: 'button',
+ },
+ defaultValue: 1,
+ fieldName: 'status',
+ label: '状态',
+ rules: 'required',
+ },
+];
+
+export const drawerColumns: VxeGridProps['columns'] = [
+ {
+ type: 'checkbox',
+ title: '规则名称',
+ align: 'left',
+ },
+];
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/data-permission/scope/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/data-permission/scope/index.vue
new file mode 100644
index 0000000..1c1c947
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/data-permission/scope/index.vue
@@ -0,0 +1,235 @@
+
+
+
+
+
+
+ modalApi.setData(null).open()">
+
+ 新增数据范围
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/dept/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/dept/data.ts
new file mode 100644
index 0000000..52eaa62
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/dept/data.ts
@@ -0,0 +1,158 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
+import type { SysDeptTreeResult } from '#/api';
+
+import { $t } from '@vben/locales';
+
+import { z } from '#/adapter/form';
+import { getSysDeptTreeApi } from '#/api';
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+export const querySchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '部门名称',
+ },
+ {
+ component: 'Input',
+ fieldName: 'leader',
+ label: '负责人',
+ },
+ {
+ component: 'Input',
+ fieldName: 'phone',
+ label: '手机号码',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ // options: [
+ // {
+ // label: '正常',
+ // value: 1,
+ // },
+ // {
+ // label: '停用',
+ // value: 0,
+ // },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS),
+ },
+ fieldName: 'status',
+ label: $t('common.form.status'),
+ },
+];
+
+export function useColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ { field: 'name', title: '名称', align: 'left', treeNode: true },
+ { field: 'leader', title: '负责人' },
+ { field: 'phone', title: '手机号码' },
+ { field: 'email', title: '邮箱' },
+ { field: 'sort', title: '排序' },
+ {
+ field: 'status',
+ title: '状态',
+ cellRender: {
+ name: 'CellTag',
+ },
+ },
+ {
+ field: 'created_time',
+ title: $t('common.table.created_time'),
+ width: 168,
+ formatter: 'formatDateTime',
+ },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 200,
+ cellRender: {
+ attrs: {
+ nameField: 'name',
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: [
+ {
+ code: 'add',
+ text: '新增下级',
+ },
+ 'edit',
+ {
+ code: 'delete',
+ disabled: (row: SysDeptTreeResult) => {
+ return row.id === 1;
+ },
+ },
+ ],
+ },
+ },
+ ];
+}
+
+export const schema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '部门名称',
+ rules: 'required',
+ },
+ {
+ component: 'ApiTreeSelect',
+ componentProps: {
+ allowClear: true,
+ api: getSysDeptTreeApi,
+ class: 'w-full',
+ labelField: 'name',
+ valueField: 'id',
+ childrenField: 'children',
+ },
+ fieldName: 'parent_id',
+ label: '父级部门',
+ },
+ {
+ component: 'Input',
+ fieldName: 'leader',
+ label: '负责人',
+ },
+ {
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ },
+ fieldName: 'phone',
+ label: '手机号码',
+ },
+ {
+ component: 'Input',
+ componentProps: {
+ allowClear: true,
+ },
+ fieldName: 'email',
+ label: '邮箱地址',
+ rules: z.string().email({ message: '无效的邮箱地址' }).optional(),
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: $t('common.enabled'), value: 1 },
+ // { label: $t('common.disabled'), value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS),
+ optionType: 'button',
+ },
+ defaultValue: 1,
+ fieldName: 'status',
+ label: '状态',
+ rules: 'required',
+ },
+];
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/dept/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/dept/index.vue
new file mode 100644
index 0000000..0cc588d
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/dept/index.vue
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+ modalApi.setData(null).open()">
+
+ 新增部门
+
+
+
+
+ 展开全部
+
+ 折叠全部
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/menu/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/menu/data.ts
new file mode 100644
index 0000000..7078f7f
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/menu/data.ts
@@ -0,0 +1,294 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
+import type { SysMenuTreeResult } from '#/api';
+
+import { $t } from '@vben/locales';
+
+import { z } from '#/adapter/form';
+import { getSysMenuTreeApi } from '#/api';
+import { componentKeys } from '#/router/routes';
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+export const querySchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'title',
+ label: '菜单标题',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ // options: [
+ // {
+ // label: '正常',
+ // value: 1,
+ // },
+ // {
+ // label: '停用',
+ // value: 0,
+ // },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS),
+ },
+ fieldName: 'status',
+ label: $t('common.form.status'),
+ },
+];
+
+export function useColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ {
+ field: 'title',
+ title: '标题',
+ align: 'left',
+ fixed: 'left',
+ slots: { default: 'title_default' },
+ treeNode: true,
+ width: 160,
+ },
+ {
+ field: 'type',
+ title: '类型',
+ width: 80,
+ cellRender: {
+ name: 'CellTag',
+ // options: [
+ // { color: 'orange', label: '目录', value: 0 },
+ // { color: 'default', label: '菜单', value: 1 },
+ // { color: 'blue', label: '按钮', value: 2 },
+ // { color: 'warning', label: '内嵌', value: 3 },
+ // { color: 'success', label: '外链', value: 4 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_MENU_TYPE),
+ },
+ },
+ { field: 'sort', title: '排序', width: 50 },
+ { field: 'perms', title: '权限标识', align: 'left', width: 160 },
+ { field: 'name', title: '菜单名称', align: 'left', width: 180 },
+ { field: 'path', title: '路由地址', align: 'left', width: 180 },
+ { field: 'component', title: '页面组件', align: 'left', width: 300 },
+ {
+ field: 'status',
+ title: '状态',
+ width: 80,
+ cellRender: {
+ name: 'CellTag',
+ },
+ },
+ { field: 'remark', title: '备注' },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 200,
+ cellRender: {
+ attrs: {
+ nameField: 'name',
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: [
+ {
+ code: 'add',
+ text: '新增子菜单',
+ disabled: (row: SysMenuTreeResult) => {
+ return [2, 3, 4].includes(row.type);
+ },
+ },
+ 'edit',
+ {
+ code: 'delete',
+ disabled: (row: SysMenuTreeResult) => {
+ return row.name === 'System' || row.name === 'Log';
+ },
+ },
+ ],
+ },
+ },
+ ];
+}
+
+export const schema: VbenFormSchema[] = [
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: '目录', value: 0 },
+ // { label: '菜单', value: 1 },
+ // { label: '按钮', value: 2 },
+ // { label: '内嵌', value: 3 },
+ // { label: '外链', value: 4 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_MENU_TYPE),
+ optionType: 'button',
+ },
+ defaultValue: 1,
+ fieldName: 'type',
+ formItemClass: 'md:col-span-2',
+ label: '菜单类型',
+ },
+ {
+ component: 'Input',
+ fieldName: 'title',
+ label: '菜单标题',
+ rules: 'required',
+ },
+ {
+ component: 'ApiTreeSelect',
+ componentProps: {
+ allowClear: true,
+ api: getSysMenuTreeApi,
+ class: 'w-full',
+ labelField: 'title',
+ valueField: 'id',
+ childrenField: 'children',
+ },
+ fieldName: 'parent_id',
+ label: '父级部门',
+ },
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '菜单名称',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ dependencies: {
+ show: (values) => {
+ return [0, 1, 3, 4].includes(values.type);
+ },
+ triggerFields: ['type'],
+ },
+ fieldName: 'path',
+ label: '路由地址',
+ rules: z.string().regex(/^\/.*/, { message: '必须以为 "/" 开始' }),
+ },
+ {
+ component: 'InputNumber',
+ componentProps: {
+ defaultValue: 0,
+ min: 0,
+ style: { width: '100%' },
+ },
+ fieldName: 'sort',
+ label: '排序',
+ },
+ {
+ component: 'IconPicker',
+ dependencies: {
+ show: (values) => {
+ return [0, 1, 3, 4].includes(values.type);
+ },
+ triggerFields: ['type'],
+ },
+ fieldName: 'icon',
+ label: '图标',
+ },
+ {
+ component: 'AutoComplete',
+ componentProps: {
+ allowClear: true,
+ class: 'w-full',
+ filterOption(input: string, option: { value: string }) {
+ return option.value.toLowerCase().includes(input.toLowerCase());
+ },
+ options: componentKeys.map((v: any) => ({ value: v })),
+ },
+ dependencies: {
+ rules: (values) => {
+ return values.type === 1 ? 'required' : null;
+ },
+ show: (values) => {
+ return values.type === 1;
+ },
+ triggerFields: ['type'],
+ },
+ fieldName: 'component',
+ label: '组件路径',
+ },
+ {
+ component: 'Input',
+ dependencies: {
+ rules: (values) => {
+ return values.type === 2 ? 'required' : null;
+ },
+ show: (values) => {
+ return [1, 2, 3].includes(values.type);
+ },
+ triggerFields: ['type'],
+ },
+ fieldName: 'perms',
+ label: '权限标识',
+ },
+ {
+ component: 'Input',
+ dependencies: {
+ show: (values) => {
+ return [3, 4].includes(values.type);
+ },
+ triggerFields: ['type'],
+ },
+ fieldName: 'link',
+ label: '链接地址',
+ rules: z.string().url($t('ui.formRules.invalidURL')),
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: $t('common.enabled'), value: 1 },
+ // { label: $t('common.disabled'), value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS),
+ optionType: 'button',
+ },
+ defaultValue: 1,
+ fieldName: 'status',
+ label: '状态',
+ rules: 'required',
+ },
+ {
+ component: 'Switch',
+ componentProps: {
+ checkedValue: 1,
+ unCheckedValue: 0,
+ },
+ defaultValue: 1,
+ dependencies: {
+ show: (values) => {
+ return values.type !== 2;
+ },
+ triggerFields: ['type'],
+ },
+ fieldName: 'display',
+ label: '是否显示',
+ },
+ {
+ component: 'Switch',
+ componentProps: {
+ checkedValue: 1,
+ unCheckedValue: 0,
+ },
+ defaultValue: 1,
+ dependencies: {
+ show: (values) => {
+ return values.type === 1;
+ },
+ triggerFields: ['type'],
+ },
+ fieldName: 'cache',
+ label: '是否缓存',
+ },
+ {
+ component: 'Textarea',
+ fieldName: 'remark',
+ label: '备注',
+ },
+];
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/menu/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/menu/index.vue
new file mode 100644
index 0000000..e8869fa
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/menu/index.vue
@@ -0,0 +1,207 @@
+
+
+
+
+
+
+ modalApi.setData(null).open()">
+
+ 新增菜单
+
+
+
+
+ 展开全部
+
+ 折叠全部
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/plugin/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/plugin/data.ts
new file mode 100644
index 0000000..35af487
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/plugin/data.ts
@@ -0,0 +1,73 @@
+import type { VbenFormSchema } from '#/adapter/form';
+
+import { h } from 'vue';
+
+import { Button } from 'antdv-next';
+
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+export function userSchema(fileList: any): VbenFormSchema[] {
+ return [
+ {
+ component: 'RadioGroup',
+ defaultValue: 0,
+ componentProps: {
+ // options: [
+ // {
+ // label: '压缩包',
+ // value: 0,
+ // },
+ // {
+ // label: 'GIT',
+ // value: 1,
+ // },
+ // ],
+ options: getDictOptions(DictEnum.SYS_PLUGIN_TYPE),
+ },
+ fieldName: 'installType',
+ label: '安装方式',
+ },
+ {
+ component: 'Upload',
+ dependencies: {
+ show: (values) => values && values.installType === 0,
+ triggerFields: ['installType'],
+ },
+ componentProps: {
+ name: 'file',
+ accept: '.zip',
+ maxCount: 1,
+ multiple: false,
+ directory: false,
+ fileList: fileList.value,
+ beforeUpload: (file: any) => {
+ fileList.value = [file];
+ return false;
+ },
+ onRemove: () => {
+ fileList.value = [];
+ },
+ },
+ renderComponentContent: () => ({
+ default: () => {
+ return h(Button, {}, { default: () => 'Upload' });
+ },
+ }),
+ fieldName: 'uploadField',
+ label: 'ZIP 压缩包',
+ rules: 'required',
+ help: '仅能上传一个 zip 压缩包文件,重新上传则覆盖',
+ },
+ {
+ component: 'Input',
+ dependencies: {
+ show: (values) => values && values.installType === 1,
+ triggerFields: ['installType'],
+ },
+ fieldName: 'repo_url',
+ label: 'GIT 地址',
+ rules: 'required',
+ help: '仓库内容无法实时检测,请谨慎操作,避免非插件代码植入',
+ },
+ ];
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/plugin/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/plugin/index.vue
new file mode 100644
index 0000000..87af048
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/plugin/index.vue
@@ -0,0 +1,240 @@
+
+
+
+
+
+ modalApi.open()">安装插件
+
+
+
+
+
+
+
+
+
+
+ {{ info.plugin.summary }}
+
+ @{{ info.plugin.author }}
+
+
+ {{ info.plugin.description }}
+
+
+
+
+
+
+ {{ info.plugin.version }}
+
+
+ 卸载
+
+
+ 打包
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/role/data-perm.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/role/data-perm.vue
new file mode 100644
index 0000000..873d8b6
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/role/data-perm.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+ 此页面仅用于数据展示,如需操作,请前往 【系统管理】 -> 【数据权限】
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/role/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/role/data.ts
new file mode 100644
index 0000000..e61aef9
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/role/data.ts
@@ -0,0 +1,239 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
+import type { SysRoleResult } from '#/api';
+
+import { $t } from '@vben/locales';
+
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+export const querySchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '角色名称',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ // options: [
+ // {
+ // label: '已启用',
+ // value: 1,
+ // },
+ // {
+ // label: '已停用',
+ // value: 0,
+ // },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS),
+ },
+ fieldName: 'status',
+ label: $t('common.form.status'),
+ },
+];
+
+export function useColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ {
+ field: 'seq',
+ title: $t('common.table.id'),
+ type: 'seq',
+ width: 50,
+ },
+ { field: 'name', title: '角色名称' },
+ {
+ field: 'is_filter_scopes',
+ title: '过滤数据权限',
+ cellRender: {
+ name: 'CellTag',
+ // options: [
+ // { color: 'success', label: $t('common.enabled'), value: true },
+ // { color: 'error', label: $t('common.disabled'), value: false },
+ // ],
+ options: getDictOptions(DictEnum.SYS_CHOOSE),
+ },
+ },
+ {
+ field: 'status',
+ title: '状态',
+ cellRender: {
+ name: 'CellTag',
+ },
+ width: 100,
+ },
+ { field: 'remark', title: $t('common.table.mark') },
+ {
+ field: 'created_time',
+ title: $t('common.table.created_time'),
+ width: 168,
+ },
+ {
+ field: 'updated_time',
+ title: $t('common.table.updated_time'),
+ width: 168,
+ },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 200,
+ cellRender: {
+ attrs: {
+ nameField: 'name',
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: [
+ {
+ code: 'perm',
+ text: '权限设置',
+ },
+ 'edit',
+ {
+ code: 'delete',
+ disabled: (row: SysRoleResult) => {
+ return row.id === 1;
+ },
+ },
+ ],
+ },
+ },
+ ];
+}
+
+export const schema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'name',
+ label: '角色名称',
+ rules: 'required',
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: $t('common.enabled'), value: true },
+ // { label: $t('common.disabled'), value: false },
+ // ],
+ options: getDictOptions(DictEnum.SYS_CHOOSE),
+ optionType: 'button',
+ },
+ defaultValue: true,
+ fieldName: 'is_filter_scopes',
+ label: '过滤数据权限',
+ rules: 'required',
+ },
+ {
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ // options: [
+ // { label: $t('common.enabled'), value: 1 },
+ // { label: $t('common.disabled'), value: 0 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS),
+ optionType: 'button',
+ },
+ defaultValue: 1,
+ fieldName: 'status',
+ label: '状态',
+ rules: 'required',
+ },
+ {
+ component: 'Textarea',
+ fieldName: 'remark',
+ label: '备注',
+ },
+];
+
+export const drawerQuerySchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'title',
+ label: '菜单标题',
+ },
+];
+
+export const drawerColumns: VxeGridProps['columns'] = [
+ {
+ type: 'checkbox',
+ title: '标题',
+ align: 'left',
+ fixed: 'left',
+ treeNode: true,
+ },
+ {
+ field: 'type',
+ title: '类型',
+ cellRender: {
+ name: 'CellTag',
+ // options: [
+ // { color: 'orange', label: '目录', value: 0 },
+ // { color: 'default', label: '菜单', value: 1 },
+ // { color: 'blue', label: '按钮', value: 2 },
+ // { color: 'warning', label: '内嵌', value: 3 },
+ // { color: 'success', label: '外链', value: 4 },
+ // ],
+ options: getDictOptions(DictEnum.SYS_MENU_TYPE),
+ },
+ },
+ { field: 'perms', title: '权限标识' },
+ { field: 'remark', title: '备注' },
+];
+
+export function drawerDataScopeColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ return [
+ {
+ type: 'checkbox',
+ title: '范围名称',
+ align: 'left',
+ fixed: 'left',
+ minWidth: 150,
+ },
+ {
+ field: 'status',
+ title: '状态',
+ cellRender: {
+ name: 'CellTag',
+ },
+ width: 100,
+ },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 200,
+ cellRender: {
+ attrs: {
+ nameField: 'name',
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: [
+ {
+ code: 'details',
+ text: '规则详情',
+ },
+ ],
+ },
+ },
+ ];
+}
+
+export const drawerDataRuleColumns: VxeGridProps['columns'] = [
+ {
+ field: 'seq',
+ title: $t('common.table.id'),
+ type: 'seq',
+ width: 50,
+ },
+ { field: 'name', title: '规则名称' },
+];
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/role/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/role/index.vue
new file mode 100644
index 0000000..be73bf2
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/role/index.vue
@@ -0,0 +1,187 @@
+
+
+
+
+
+
+ modalApi.setData(null).open()">
+
+ 新增角色
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/role/menu-perm.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/role/menu-perm.vue
new file mode 100644
index 0000000..f02779e
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/role/menu-perm.vue
@@ -0,0 +1,256 @@
+
+
+
+
+
+
+
+
+
+ 父子独立
+ 父子联动
+
+
+
+
+ 展开全部
+
+ 折叠全部
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/user/data.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/user/data.ts
new file mode 100644
index 0000000..061b841
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/user/data.ts
@@ -0,0 +1,316 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
+import type { SysRoleResult, SysUserResult } from '#/api';
+
+import { $t } from '@vben/locales';
+
+import { message } from 'antdv-next';
+
+import { z } from '#/adapter/form';
+import { getSysDeptTreeApi, updateSysUserPermissionApi } from '#/api';
+import { DictEnum, getDictOptions } from '#/utils/dict';
+
+export const querySchema: VbenFormSchema[] = [
+ {
+ component: 'Input',
+ fieldName: 'username',
+ label: '用户名',
+ },
+ {
+ component: 'Input',
+ fieldName: 'phone',
+ label: '手机号',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ // options: [
+ // {
+ // label: '正常',
+ // value: 1,
+ // },
+ // {
+ // label: '禁用',
+ // value: 0,
+ // },
+ // ],
+ options: getDictOptions(DictEnum.SYS_STATUS),
+ placeholder: $t('common.form.select'),
+ },
+ fieldName: 'status',
+ label: $t('common.form.status'),
+ },
+];
+
+export function useColumns(
+ onActionClick?: OnActionClickFn,
+): VxeGridProps['columns'] {
+ const createPermissionSwitch = (
+ type: string,
+ props?: Record,
+ ) => ({
+ name: 'CellSwitch',
+ attrs: {
+ async beforeChange(_checked: any, row: SysUserResult) {
+ await updateSysUserPermissionApi(row.id, type);
+ message.success($t('ui.actionMessage.operationSuccess'));
+ },
+ },
+ props,
+ });
+
+ return [
+ {
+ field: 'seq',
+ title: $t('common.table.id'),
+ type: 'seq',
+ fixed: 'left',
+ width: 50,
+ },
+ { field: 'username', title: '用户名', fixed: 'left', width: 100 },
+ { field: 'nickname', title: '昵称', width: 100 },
+ {
+ field: 'avatar',
+ title: '头像',
+ width: 80,
+ slots: { default: 'avatar' },
+ },
+ {
+ field: 'dept',
+ title: '部门',
+ width: 120,
+ slots: { default: 'dept' },
+ },
+ {
+ field: 'roles',
+ title: '角色',
+ width: 200,
+ showOverflow: 'ellipsis',
+ slots: { default: 'roles' },
+ },
+ {
+ field: 'phone',
+ title: '手机号',
+ width: 150,
+ formatter({ cellValue }) {
+ return cellValue || '暂无';
+ },
+ },
+ {
+ field: 'email',
+ title: '邮箱',
+ width: 150,
+ formatter({ cellValue }) {
+ return cellValue || '暂无';
+ },
+ },
+ {
+ field: 'status',
+ title: '状态',
+ width: 100,
+ cellRender: createPermissionSwitch('status'),
+ },
+ {
+ field: 'is_superuser',
+ title: '超级管理员',
+ width: 100,
+ cellRender: createPermissionSwitch('superuser', {
+ checkedValue: true,
+ unCheckedValue: false,
+ }),
+ },
+ {
+ field: 'is_staff',
+ title: '后台登录',
+ width: 100,
+ cellRender: createPermissionSwitch('staff', {
+ checkedValue: true,
+ unCheckedValue: false,
+ }),
+ },
+ {
+ field: 'is_multi_login',
+ title: '多端登录',
+ width: 100,
+ cellRender: createPermissionSwitch('multi_login', {
+ checkedValue: true,
+ unCheckedValue: false,
+ }),
+ },
+ {
+ field: 'join_time',
+ title: '注册时间',
+ width: 168,
+ },
+ {
+ field: 'last_login_time',
+ title: '最后登录时间',
+ width: 168,
+ },
+ {
+ field: 'operation',
+ title: $t('common.table.operation'),
+ align: 'center',
+ fixed: 'right',
+ width: 150,
+ cellRender: {
+ attrs: {
+ nameField: 'username',
+ onClick: onActionClick,
+ },
+ name: 'CellOperation',
+ options: [
+ 'edit',
+ {
+ code: 'delete',
+ disabled: (row: SysUserResult) => {
+ return row.username === 'admin';
+ },
+ },
+ {
+ code: 'more',
+ items: [{ code: 'reset_password', text: '重置密码' }],
+ },
+ ],
+ },
+ },
+ ];
+}
+
+export function useEditSchema(roleSelectOptions: any): VbenFormSchema[] {
+ return [
+ {
+ component: 'Input',
+ fieldName: 'username',
+ label: '用户名',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'nickname',
+ label: '昵称',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'avatar',
+ label: '头像地址',
+ },
+ {
+ component: 'Input',
+ fieldName: 'phone',
+ label: '手机号码',
+ },
+ {
+ component: 'Input',
+ fieldName: 'email',
+ label: '邮箱',
+ },
+ {
+ component: 'ApiTreeSelect',
+ componentProps: {
+ allowClear: true,
+ api: getSysDeptTreeApi,
+ class: 'w-full',
+ labelField: 'name',
+ valueField: 'id',
+ childrenField: 'children',
+ },
+ fieldName: 'dept_id',
+ label: '所属部门',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ class: 'w-full',
+ mode: 'multiple',
+ options: roleSelectOptions,
+ fieldNames: { label: 'name', value: 'id' },
+ filterOption: (input: string, option: SysRoleResult) => {
+ return (
+ option.name?.toLowerCase()?.includes(input.toLowerCase()) ?? false
+ );
+ },
+ },
+ fieldName: 'roles',
+ label: '角色',
+ rules: 'selectRequired',
+ },
+ ];
+}
+
+export function useAddSchema(roleSelectOptions: any): VbenFormSchema[] {
+ return [
+ {
+ component: 'Input',
+ fieldName: 'username',
+ label: '用户名',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'nickname',
+ label: '昵称',
+ },
+ {
+ component: 'InputPassword',
+ fieldName: 'password',
+ label: '密码',
+ rules: 'required',
+ },
+ {
+ component: 'Input',
+ fieldName: 'phone',
+ label: '手机号码',
+ },
+ {
+ component: 'Input',
+ fieldName: 'email',
+ label: '邮箱',
+ },
+ {
+ component: 'ApiTreeSelect',
+ componentProps: {
+ allowClear: true,
+ api: getSysDeptTreeApi,
+ class: 'w-full',
+ labelField: 'name',
+ valueField: 'id',
+ childrenField: 'children',
+ },
+ fieldName: 'dept_id',
+ label: '所属部门',
+ rules: 'required',
+ },
+ {
+ component: 'Select',
+ componentProps: {
+ class: 'w-full',
+ mode: 'multiple',
+ options: roleSelectOptions,
+ fieldNames: { label: 'name', value: 'id' },
+ filterOption: (input: string, option: SysRoleResult) => {
+ return (
+ option.name?.toLowerCase()?.includes(input.toLowerCase()) ?? false
+ );
+ },
+ },
+ fieldName: 'roles',
+ label: '角色',
+ rules: 'selectRequired',
+ },
+ ];
+}
+
+export const resetPwdSchema: VbenFormSchema[] = [
+ {
+ component: 'InputPassword',
+ fieldName: 'password',
+ label: '新密码',
+ rules: z
+ .string({ message: '请输入新密码' })
+ .min(6, '密码长度不能少于 6 个字符')
+ .max(20, '密码长度不能超过 20 个字符'),
+ },
+];
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/user/index.vue b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/user/index.vue
new file mode 100644
index 0000000..4ce1aec
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/src/views/system/user/index.vue
@@ -0,0 +1,379 @@
+
+
+
+
+
+
+
+
+
+
Xxx集团
+
+
+
+ {{ name.substring(0, name.indexOf(searchDeptValue)) }}
+ {{ searchDeptValue }}
+ {{
+ name.substring(
+ name.indexOf(searchDeptValue) + searchDeptValue.length,
+ )
+ }}
+
+ {{ name }}
+
+
+
+
+
+
+
+
+
+ addModalApi.setData(null).open()">
+
+ 添加用户
+
+
+
+
+
+
+
+
+ {{ row.dept.name }}
+
+
+ 未绑定
+
+
+
+
+ {{ row.roles[0]?.name }}
+
+
+
+
+
+
+ {{ role.name }}
+
+
+
+ {{ role.name }}
+
+
+
+ 未绑定
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/tsconfig.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/tsconfig.json
new file mode 100644
index 0000000..858a0ec
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "@vben/tsconfig/web-app.json",
+ "compilerOptions": {
+ "paths": {
+ "#/*": ["./src/*"]
+ }
+ },
+ "references": [{ "path": "./tsconfig.node.json" }],
+ "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/tsconfig.node.json b/deploy/fba/fba-ui-src/apps/web-antdv-next/tsconfig.node.json
new file mode 100644
index 0000000..36e9fb5
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "@vben/tsconfig/node.json",
+ "compilerOptions": {
+ "composite": true,
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "noEmit": false
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/deploy/fba/fba-ui-src/apps/web-antdv-next/vite.config.ts b/deploy/fba/fba-ui-src/apps/web-antdv-next/vite.config.ts
new file mode 100644
index 0000000..b3b1140
--- /dev/null
+++ b/deploy/fba/fba-ui-src/apps/web-antdv-next/vite.config.ts
@@ -0,0 +1,16 @@
+import { fileURLToPath, URL } from 'node:url';
+
+import { defineConfig } from '@vben/vite-config';
+
+export default defineConfig(async () => {
+ return {
+ application: {},
+ vite: {
+ resolve: {
+ alias: {
+ '#': fileURLToPath(new URL('src', import.meta.url)),
+ },
+ },
+ },
+ };
+});
diff --git a/deploy/fba/fba-ui-src/cspell.json b/deploy/fba/fba-ui-src/cspell.json
new file mode 100644
index 0000000..d852439
--- /dev/null
+++ b/deploy/fba/fba-ui-src/cspell.json
@@ -0,0 +1,100 @@
+{
+ "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
+ "version": "0.2",
+ "language": "en,en-US",
+ "allowCompoundWords": true,
+ "words": [
+ "acmr",
+ "aliyun",
+ "antd",
+ "antdv",
+ "archiver",
+ "astro",
+ "axios",
+ "brotli",
+ "cascader",
+ "chatcmpl",
+ "clsx",
+ "cuida",
+ "datas",
+ "dedup",
+ "defu",
+ "demi",
+ "depts",
+ "dotenv",
+ "echart",
+ "echarts",
+ "ependencies",
+ "esbuild",
+ "esno",
+ "etag",
+ "execa",
+ "iconify",
+ "iconoir",
+ "indexeddb",
+ "intlify",
+ "isequal",
+ "jspm",
+ "kwargs",
+ "lockb",
+ "lucide",
+ "minh",
+ "minw",
+ "mkdist",
+ "mockjs",
+ "myapp",
+ "naiveui",
+ "napi",
+ "nocheck",
+ "nolebase",
+ "noopener",
+ "noreferrer",
+ "nprogress",
+ "nuxt",
+ "organisation",
+ "oxfmt",
+ "oxlint",
+ "pinia",
+ "prefixs",
+ "publint",
+ "pydantic",
+ "qrcode",
+ "reka",
+ "rollup",
+ "shadcn",
+ "sonner",
+ "sortablejs",
+ "sqla",
+ "styl",
+ "tabler",
+ "taze",
+ "tdesign",
+ "tsdown",
+ "tsgolint",
+ "turborepo",
+ "ui-kit",
+ "uicons",
+ "unplugin",
+ "unref",
+ "vben",
+ "vbenjs",
+ "vite",
+ "vitejs",
+ "vitepress",
+ "vitest",
+ "vnode",
+ "vueuse",
+ "yxxx"
+ ],
+ "ignorePaths": [
+ "**/*-dist/**",
+ "**/*.log",
+ "**/*.spec.ts",
+ "**/*.test.ts",
+ "**/__tests__/**",
+ "**/dist/**",
+ "**/icons/**",
+ "**/node_modules/**",
+ "pnpm-lock.yaml"
+ ]
+}
diff --git a/deploy/fba/fba-ui-src/docker-compose.yml b/deploy/fba/fba-ui-src/docker-compose.yml
new file mode 100644
index 0000000..6e8a4e3
--- /dev/null
+++ b/deploy/fba/fba-ui-src/docker-compose.yml
@@ -0,0 +1,42 @@
+networks:
+ fba_network:
+ # name: fba_network
+ # driver: bridge
+ external: true
+
+volumes:
+ fba_static:
+ external: true
+ fba_static_upload:
+ external: true
+
+services:
+ fba_ui:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: fba_ui:latest
+ ports:
+ - '80:80'
+ - '443:443'
+ container_name: fba_ui
+ restart: always
+ command:
+ - nginx
+ - -g
+ - daemon off;
+ volumes:
+ # nginx https conf
+ # 通过 docker 进行部署时,需要打开此配置项并确保<挂载到容器内的证书文件路径>配置
+ # 与 nginx conf 中的 ssl 证书文件路径配置一致,如果你直接将 ssl 证书文件 cp
+ # 到了 docker 容器内,则无需挂载证书文件,直接将它们注释或删除即可
+ # local_ssl_pem_path:你在服务器存放 ssl pem 证书文件的路径,自行修改
+ # local_ssl_key_path: 你在服务器存放 ssl key 证书文件的路径,自行修改
+ # /etc/ssl/xxx.pem:挂载到容器内 ssl pem 证书文件的路径,自行修改
+ # /etc/ssl/xxx.key:挂载到容器内 ssl key 证书文件的路径,自行修改
+ - local_ssl_pem_path:/etc/ssl/xxx.pem
+ - local_ssl_key_path:/etc/ssl/xxx.key
+ - fba_static:/var/www/fba_server/backend/static
+ - fba_static_upload:/www/fba_server/backend/static/upload
+ networks:
+ - fba_network
diff --git a/deploy/fba/fba-ui-src/eslint.config.mjs b/deploy/fba/fba-ui-src/eslint.config.mjs
new file mode 100644
index 0000000..d9f85a3
--- /dev/null
+++ b/deploy/fba/fba-ui-src/eslint.config.mjs
@@ -0,0 +1,16 @@
+import { defineConfig } from '@vben/eslint-config';
+
+export default defineConfig([
+ {
+ files: ['apps/*/src/plugins/**/*'],
+ rules: {
+ 'n/no-extraneous-import': 'off',
+ },
+ },
+ {
+ files: ['apps/*/src/plugins/**/package.json'],
+ rules: {
+ 'pnpm/json-enforce-catalog': 'off',
+ },
+ },
+]);
diff --git a/deploy/fba/fba-ui-src/internal/lint-configs/commitlint-config/index.d.ts b/deploy/fba/fba-ui-src/internal/lint-configs/commitlint-config/index.d.ts
new file mode 100644
index 0000000..14b40f1
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/lint-configs/commitlint-config/index.d.ts
@@ -0,0 +1,6 @@
+// eslint-disable-next-line n/no-extraneous-import
+import type { UserConfig } from '@commitlint/types';
+
+declare const userConfig: UserConfig;
+
+export default userConfig;
diff --git a/deploy/fba/fba-ui-src/internal/lint-configs/commitlint-config/index.mjs b/deploy/fba/fba-ui-src/internal/lint-configs/commitlint-config/index.mjs
new file mode 100644
index 0000000..36c3b09
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/lint-configs/commitlint-config/index.mjs
@@ -0,0 +1,153 @@
+import { execSync } from 'node:child_process';
+
+import { getPackagesSync } from '@vben/node-utils';
+
+const { packages } = getPackagesSync();
+
+const allowedScopes = [
+ ...packages.map((pkg) => pkg.packageJson.name),
+ 'project',
+ 'style',
+ 'lint',
+ 'ci',
+ 'dev',
+ 'deploy',
+ 'other',
+];
+
+// precomputed scope
+const scopeComplete = execSync('git status --porcelain || true')
+ .toString()
+ .trim()
+ .split('\n')
+ .find((r) => ~r.indexOf('M src'))
+ ?.replaceAll(/(\/)/g, '%%')
+ ?.match(/src%%((\w|-)*)/)?.[1]
+ ?.replace(/s$/, '');
+
+/**
+ * @type {import('cz-git').UserConfig}
+ */
+const userConfig = {
+ extends: ['@commitlint/config-conventional'],
+ plugins: ['commitlint-plugin-function-rules'],
+ prompt: {
+ /** @use `pnpm commit :f` */
+ alias: {
+ b: 'build: bump dependencies',
+ c: 'chore: update config',
+ f: 'docs: fix typos',
+ r: 'docs: update README',
+ s: 'style: update code format',
+ },
+ allowCustomIssuePrefixs: false,
+ // scopes: [...scopes, 'mock'],
+ allowEmptyIssuePrefixs: false,
+ customScopesAlign: scopeComplete ? 'bottom' : 'top',
+ defaultScope: scopeComplete,
+ // English
+ typesAppend: [
+ { name: 'workflow: workflow improvements', value: 'workflow' },
+ { name: 'types: type definition file changes', value: 'types' },
+ ],
+
+ // 中英文对照版
+ // messages: {
+ // type: '选择你要提交的类型 :',
+ // scope: '选择一个提交范围 (可选):',
+ // customScope: '请输入自定义的提交范围 :',
+ // subject: '填写简短精炼的变更描述 :\n',
+ // body: '填写更加详细的变更描述 (可选)。使用 "|" 换行 :\n',
+ // breaking: '列举非兼容性重大的变更 (可选)。使用 "|" 换行 :\n',
+ // footerPrefixsSelect: '选择关联issue前缀 (可选):',
+ // customFooterPrefixs: '输入自定义issue前缀 :',
+ // footer: '列举关联issue (可选) 例如: #31, #I3244 :\n',
+ // confirmCommit: '是否提交或修改commit ?',
+ // },
+ // types: [
+ // { value: 'feat', name: 'feat: 新增功能' },
+ // { value: 'fix', name: 'fix: 修复缺陷' },
+ // { value: 'docs', name: 'docs: 文档变更' },
+ // { value: 'style', name: 'style: 代码格式' },
+ // { value: 'refactor', name: 'refactor: 代码重构' },
+ // { value: 'perf', name: 'perf: 性能优化' },
+ // { value: 'test', name: 'test: 添加疏漏测试或已有测试改动' },
+ // { value: 'build', name: 'build: 构建流程、外部依赖变更 (如升级 npm 包、修改打包配置等)' },
+ // { value: 'ci', name: 'ci: 修改 CI 配置、脚本' },
+ // { value: 'revert', name: 'revert: 回滚 commit' },
+ // { value: 'chore', name: 'chore: 对构建过程或辅助工具和库的更改 (不影响源文件、测试用例)' },
+ // { value: 'wip', name: 'wip: 正在开发中' },
+ // { value: 'workflow', name: 'workflow: 工作流程改进' },
+ // { value: 'types', name: 'types: 类型定义文件修改' },
+ // ],
+ // emptyScopesAlias: 'empty: 不填写',
+ // customScopesAlias: 'custom: 自定义',
+ },
+ rules: {
+ /**
+ * type[scope]: [function] description
+ *
+ * ^^^^^^^^^^^^^^ empty line.
+ * - Something here
+ */
+ 'body-leading-blank': [2, 'always'],
+ /**
+ * type[scope]: [function] description
+ *
+ * - something here
+ *
+ * ^^^^^^^^^^^^^^
+ */
+ 'footer-leading-blank': [1, 'always'],
+ /**
+ * type[scope]: [function] description
+ * ^^^^^
+ */
+ 'function-rules/scope-enum': [
+ 2, // level: error
+ 'always',
+ (parsed) => {
+ if (!parsed.scope || allowedScopes.includes(parsed.scope)) {
+ return [true];
+ }
+
+ return [false, `scope must be one of ${allowedScopes.join(', ')}`];
+ },
+ ],
+ /**
+ * type[scope]: [function] description [No more than 108 characters]
+ * ^^^^^
+ */
+ 'header-max-length': [2, 'always', 108],
+
+ 'scope-enum': [0],
+ 'subject-case': [0],
+ 'subject-empty': [2, 'never'],
+ 'type-empty': [2, 'never'],
+ /**
+ * type[scope]: [function] description
+ * ^^^^
+ */
+ 'type-enum': [
+ 2,
+ 'always',
+ [
+ 'feat',
+ 'fix',
+ 'perf',
+ 'style',
+ 'docs',
+ 'test',
+ 'refactor',
+ 'build',
+ 'ci',
+ 'chore',
+ 'revert',
+ 'types',
+ 'release',
+ ],
+ ],
+ },
+};
+
+export default userConfig;
diff --git a/deploy/fba/fba-ui-src/internal/lint-configs/commitlint-config/package.json b/deploy/fba/fba-ui-src/internal/lint-configs/commitlint-config/package.json
new file mode 100644
index 0000000..5803948
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/lint-configs/commitlint-config/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@vben/commitlint-config",
+ "version": "5.7.0",
+ "private": true,
+ "homepage": "https://github.com/vbenjs/vue-vben-admin",
+ "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/vbenjs/vue-vben-admin.git",
+ "directory": "internal/lint-configs/commitlint-config"
+ },
+ "license": "MIT",
+ "type": "module",
+ "files": [
+ "dist"
+ ],
+ "main": "./index.mjs",
+ "module": "./index.mjs",
+ "exports": {
+ ".": {
+ "types": "./index.d.ts",
+ "import": "./index.mjs",
+ "default": "./index.mjs"
+ }
+ },
+ "dependencies": {
+ "@commitlint/cli": "catalog:",
+ "@commitlint/config-conventional": "catalog:",
+ "@vben/node-utils": "workspace:*",
+ "commitlint-plugin-function-rules": "catalog:",
+ "cz-git": "catalog:",
+ "czg": "catalog:"
+ }
+}
diff --git a/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/package.json b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/package.json
new file mode 100644
index 0000000..c59d4d4
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@vben/eslint-config",
+ "version": "5.7.0",
+ "private": true,
+ "homepage": "https://github.com/vbenjs/vue-vben-admin",
+ "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/vbenjs/vue-vben-admin.git",
+ "directory": "internal/lint-configs/eslint-config"
+ },
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "stub": "pnpm exec tsdown"
+ },
+ "files": [
+ "dist"
+ ],
+ "main": "./dist/index.mjs",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.mjs"
+ }
+ },
+ "dependencies": {
+ "@eslint/js": "catalog:",
+ "@typescript-eslint/parser": "catalog:",
+ "@vben/oxlint-config": "workspace:*",
+ "eslint": "catalog:",
+ "eslint-plugin-jsonc": "catalog:",
+ "eslint-plugin-n": "catalog:",
+ "eslint-plugin-perfectionist": "catalog:",
+ "eslint-plugin-pnpm": "catalog:",
+ "eslint-plugin-unused-imports": "catalog:",
+ "eslint-plugin-vue": "catalog:",
+ "eslint-plugin-yml": "catalog:",
+ "globals": "catalog:",
+ "vue-eslint-parser": "catalog:",
+ "yaml-eslint-parser": "catalog:"
+ }
+}
diff --git a/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/ignores.ts b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/ignores.ts
new file mode 100644
index 0000000..6bbb350
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/ignores.ts
@@ -0,0 +1,61 @@
+import type { Linter } from 'eslint';
+
+export async function ignores(): Promise {
+ return [
+ {
+ ignores: [
+ '**/node_modules',
+ '**/dist',
+ '**/dist-*',
+ '**/*-dist',
+ '**/.husky',
+ '**/.nitro',
+ '**/.output',
+ '**/Dockerfile',
+ '**/package-lock.json',
+ '**/yarn.lock',
+ '**/pnpm-lock.yaml',
+ '**/bun.lockb',
+ '**/output',
+ '**/coverage',
+ '**/temp',
+ '**/.temp',
+ '**/tmp',
+ '**/.tmp',
+ '**/.history',
+ '**/.turbo',
+ '**/.nuxt',
+ '**/.next',
+ '**/.vercel',
+ '**/.changeset',
+ '**/.idea',
+ '**/.cache',
+ '**/.output',
+ '**/.vite-inspect',
+
+ '**/CHANGELOG*.md',
+ '**/*.min.*',
+ '**/LICENSE*',
+ '**/__snapshots__',
+ '**/*.snap',
+ '**/fixtures/**',
+ '**/.vitepress/cache/**',
+ '**/auto-import?(s).d.ts',
+ '**/components.d.ts',
+ '**/types/antd.d.ts',
+ '**/vite.config.mts.*',
+ '**/*.sh',
+ '**/*.ttf',
+ '**/*.woff',
+ '**/.github',
+ '**/lefthook.yml',
+
+ '**/.agent/**',
+ '**/.agents/**',
+ '**/.codex/**',
+ '**/.claude/**',
+ '**/.cursor/**',
+ ],
+ },
+ ];
+}
diff --git a/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/index.ts b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/index.ts
new file mode 100644
index 0000000..c1c2b4c
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/index.ts
@@ -0,0 +1,9 @@
+export * from './ignores';
+export * from './javascript';
+export * from './jsonc';
+export * from './node';
+export * from './perfectionist';
+export * from './pnpm';
+export * from './typescript';
+export * from './vue';
+export * from './yaml';
diff --git a/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/javascript.ts b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/javascript.ts
new file mode 100644
index 0000000..bd9bd85
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/javascript.ts
@@ -0,0 +1,182 @@
+import type { Linter } from 'eslint';
+
+import js from '@eslint/js';
+import pluginUnusedImports from 'eslint-plugin-unused-imports';
+import globals from 'globals';
+
+const rulesCoveredByOxlint = new Set([
+ 'constructor-super',
+ 'for-direction',
+ 'getter-return',
+ 'no-async-promise-executor',
+ 'no-case-declarations',
+ 'no-class-assign',
+ 'no-compare-neg-zero',
+ 'no-cond-assign',
+ 'no-const-assign',
+ 'no-constant-binary-expression',
+ 'no-constant-condition',
+ 'no-debugger',
+ 'no-delete-var',
+ 'no-dupe-args',
+ 'no-dupe-class-members',
+ 'no-dupe-else-if',
+ 'no-dupe-keys',
+ 'no-duplicate-case',
+ 'no-empty',
+ 'no-empty-character-class',
+ 'no-empty-pattern',
+ 'no-empty-static-block',
+ 'no-ex-assign',
+ 'no-extra-boolean-cast',
+ 'no-fallthrough',
+ 'no-func-assign',
+ 'no-global-assign',
+ 'no-import-assign',
+ 'no-invalid-regexp',
+ 'no-irregular-whitespace',
+ 'no-loss-of-precision',
+ 'no-misleading-character-class',
+ 'no-new-native-nonconstructor',
+ 'no-nonoctal-decimal-escape',
+ 'no-obj-calls',
+ 'no-prototype-builtins',
+ 'no-redeclare',
+ 'no-regex-spaces',
+ 'no-self-assign',
+ 'no-setter-return',
+ 'no-shadow-restricted-names',
+ 'no-sparse-arrays',
+ 'no-this-before-super',
+ 'no-unreachable',
+ 'no-unsafe-finally',
+ 'no-unsafe-negation',
+ 'no-unsafe-optional-chaining',
+ 'no-unused-labels',
+ 'no-unused-private-class-members',
+ 'no-unused-vars',
+ 'no-useless-backreference',
+ 'no-useless-catch',
+ 'no-useless-escape',
+ 'no-with',
+ 'require-yield',
+ 'use-isnan',
+ 'valid-typeof',
+]);
+
+export async function javascript(): Promise {
+ const recommendedRules = Object.fromEntries(
+ Object.entries(js.configs.recommended.rules).filter(
+ ([ruleName]) => !rulesCoveredByOxlint.has(ruleName),
+ ),
+ );
+
+ return [
+ {
+ languageOptions: {
+ ecmaVersion: 'latest',
+ globals: {
+ ...globals.browser,
+ ...globals.es2021,
+ ...globals.node,
+ document: 'readonly',
+ navigator: 'readonly',
+ window: 'readonly',
+ },
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ },
+ sourceType: 'module',
+ },
+ linterOptions: {
+ reportUnusedDisableDirectives: true,
+ },
+ plugins: {
+ 'unused-imports': pluginUnusedImports,
+ },
+ rules: {
+ ...recommendedRules,
+ 'dot-notation': ['error', { allowKeywords: true }],
+ 'keyword-spacing': 'off',
+ 'no-control-regex': 'error',
+ 'no-empty-function': 'off',
+ 'no-octal': 'error',
+ 'no-octal-escape': 'error',
+ 'no-restricted-properties': [
+ 'error',
+ {
+ message:
+ 'Use `Object.getPrototypeOf` or `Object.setPrototypeOf` instead.',
+ property: '__proto__',
+ },
+ {
+ message: 'Use `Object.defineProperty` instead.',
+ property: '__defineGetter__',
+ },
+ {
+ message: 'Use `Object.defineProperty` instead.',
+ property: '__defineSetter__',
+ },
+ {
+ message: 'Use `Object.getOwnPropertyDescriptor` instead.',
+ property: '__lookupGetter__',
+ },
+ {
+ message: 'Use `Object.getOwnPropertyDescriptor` instead.',
+ property: '__lookupSetter__',
+ },
+ ],
+ 'no-restricted-syntax': [
+ 'error',
+ 'DebuggerStatement',
+ 'LabeledStatement',
+ 'WithStatement',
+ 'TSEnumDeclaration[const=true]',
+ 'TSExportAssignment',
+ ],
+ 'no-undef-init': 'error',
+ 'no-undef': 'off',
+ 'no-unreachable-loop': 'error',
+ 'object-shorthand': [
+ 'error',
+ 'always',
+ {
+ avoidQuotes: true,
+ ignoreConstructors: false,
+ },
+ ],
+ 'one-var': ['error', { initialized: 'never' }],
+ 'prefer-arrow-callback': [
+ 'error',
+ {
+ allowNamedFunctions: false,
+ allowUnboundThis: true,
+ },
+ ],
+ 'prefer-regex-literals': [
+ 'error',
+ {
+ disallowRedundantWrapping: true,
+ },
+ ],
+ 'spaced-comment': 'error',
+ 'space-before-function-paren': 'off',
+
+ 'unused-imports/no-unused-imports': 'error',
+ 'unused-imports/no-unused-vars': [
+ 'error',
+ {
+ args: 'after-used',
+ argsIgnorePattern: '^_',
+ vars: 'all',
+ varsIgnorePattern: '^_',
+ },
+ ],
+ },
+ },
+ ];
+}
diff --git a/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/jsonc.ts b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/jsonc.ts
new file mode 100644
index 0000000..3f33d13
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/jsonc.ts
@@ -0,0 +1,269 @@
+import type { Linter } from 'eslint';
+
+import { interopDefault } from '../util';
+
+export async function jsonc(): Promise {
+ const pluginJsonc = await interopDefault(import('eslint-plugin-jsonc'));
+
+ return [
+ {
+ files: ['**/*.json', '**/*.json5', '**/*.jsonc', '*.code-workspace'],
+ language: 'jsonc/x',
+ plugins: {
+ jsonc: pluginJsonc as any,
+ },
+ rules: {
+ 'jsonc/no-bigint-literals': 'error',
+ 'jsonc/no-binary-expression': 'error',
+ 'jsonc/no-binary-numeric-literals': 'error',
+ 'jsonc/no-dupe-keys': 'error',
+ 'jsonc/no-escape-sequence-in-identifier': 'error',
+ 'jsonc/no-floating-decimal': 'error',
+ 'jsonc/no-hexadecimal-numeric-literals': 'error',
+ 'jsonc/no-infinity': 'error',
+ 'jsonc/no-multi-str': 'error',
+ 'jsonc/no-nan': 'error',
+ 'jsonc/no-number-props': 'error',
+ 'jsonc/no-numeric-separators': 'error',
+ 'jsonc/no-octal': 'error',
+ 'jsonc/no-octal-escape': 'error',
+ 'jsonc/no-octal-numeric-literals': 'error',
+ 'jsonc/no-parenthesized': 'error',
+ 'jsonc/no-plus-sign': 'error',
+ 'jsonc/no-regexp-literals': 'error',
+ 'jsonc/no-sparse-arrays': 'error',
+ 'jsonc/no-template-literals': 'error',
+ 'jsonc/no-undefined-value': 'error',
+ 'jsonc/no-unicode-codepoint-escapes': 'error',
+ 'jsonc/no-useless-escape': 'error',
+ 'jsonc/space-unary-ops': 'error',
+ 'jsonc/valid-json-number': 'error',
+ 'jsonc/vue-custom-block/no-parsing-error': 'error',
+ },
+ },
+ sortTsconfig(),
+ sortPackageJson(),
+ sortCspellJson(),
+ ];
+}
+
+function sortPackageJson(): Linter.Config {
+ return {
+ files: ['**/package.json'],
+ rules: {
+ 'jsonc/sort-array-values': [
+ 'error',
+ {
+ order: { type: 'asc' },
+ pathPattern: '^files$|^pnpm.neverBuiltDependencies$',
+ },
+ ],
+ 'jsonc/sort-keys': [
+ 'error',
+ {
+ order: [
+ 'name',
+ 'version',
+ 'description',
+ 'private',
+ 'keywords',
+ 'homepage',
+ 'bugs',
+ 'repository',
+ 'license',
+ 'author',
+ 'contributors',
+ 'categories',
+ 'funding',
+ 'type',
+ 'scripts',
+ 'files',
+ 'sideEffects',
+ 'bin',
+ 'main',
+ 'module',
+ 'unpkg',
+ 'jsdelivr',
+ 'types',
+ 'typesVersions',
+ 'imports',
+ 'exports',
+ 'publishConfig',
+ 'icon',
+ 'activationEvents',
+ 'contributes',
+ 'peerDependencies',
+ 'peerDependenciesMeta',
+ 'dependencies',
+ 'optionalDependencies',
+ 'devDependencies',
+ 'engines',
+ 'packageManager',
+ 'pnpm',
+ 'overrides',
+ 'resolutions',
+ 'husky',
+ 'simple-git-hooks',
+ 'lint-staged',
+ 'eslintConfig',
+ ],
+ pathPattern: '^$',
+ },
+ {
+ order: { type: 'asc' },
+ pathPattern: '^(?:dev|peer|optional|bundled)?[Dd]ependencies(Meta)?$',
+ },
+ {
+ order: { type: 'asc' },
+ pathPattern: '^(?:resolutions|overrides|pnpm.overrides)$',
+ },
+ {
+ order: ['types', 'import', 'require', 'default'],
+ pathPattern: '^exports.*$',
+ },
+ ],
+ },
+ };
+}
+
+function sortCspellJson(): Linter.Config {
+ return {
+ files: ['**/cspell.json', '**/.cspell.json'],
+ rules: {
+ 'jsonc/sort-array-values': [
+ 'error',
+ {
+ order: { type: 'asc' },
+ pathPattern: '^words$|^ignorePaths$',
+ },
+ ],
+ },
+ };
+}
+
+function sortTsconfig(): Linter.Config {
+ return {
+ files: [
+ '**/tsconfig.json',
+ '**/tsconfig.*.json',
+ 'internal/tsconfig/*.json',
+ ],
+ rules: {
+ 'jsonc/sort-keys': [
+ 'error',
+ {
+ order: [
+ 'extends',
+ 'compilerOptions',
+ 'references',
+ 'files',
+ 'include',
+ 'exclude',
+ ],
+ pathPattern: '^$',
+ },
+ {
+ order: [
+ /* Projects */
+ 'incremental',
+ 'composite',
+ 'tsBuildInfoFile',
+ 'disableSourceOfProjectReferenceRedirect',
+ 'disableSolutionSearching',
+ 'disableReferencedProjectLoad',
+ /* Language and Environment */
+ 'target',
+ 'jsx',
+ 'jsxFactory',
+ 'jsxFragmentFactory',
+ 'jsxImportSource',
+ 'lib',
+ 'moduleDetection',
+ 'noLib',
+ 'reactNamespace',
+ 'useDefineForClassFields',
+ 'emitDecoratorMetadata',
+ 'experimentalDecorators',
+ /* Modules */
+ 'baseUrl',
+ 'rootDir',
+ 'rootDirs',
+ 'customConditions',
+ 'module',
+ 'moduleResolution',
+ 'moduleSuffixes',
+ 'noResolve',
+ 'paths',
+ 'resolveJsonModule',
+ 'resolvePackageJsonExports',
+ 'resolvePackageJsonImports',
+ 'typeRoots',
+ 'types',
+ 'allowArbitraryExtensions',
+ 'allowImportingTsExtensions',
+ 'allowUmdGlobalAccess',
+ /* JavaScript Support */
+ 'allowJs',
+ 'checkJs',
+ 'maxNodeModuleJsDepth',
+ /* Type Checking */
+ 'strict',
+ 'strictBindCallApply',
+ 'strictFunctionTypes',
+ 'strictNullChecks',
+ 'strictPropertyInitialization',
+ 'allowUnreachableCode',
+ 'allowUnusedLabels',
+ 'alwaysStrict',
+ 'exactOptionalPropertyTypes',
+ 'noFallthroughCasesInSwitch',
+ 'noImplicitAny',
+ 'noImplicitOverride',
+ 'noImplicitReturns',
+ 'noImplicitThis',
+ 'noPropertyAccessFromIndexSignature',
+ 'noUncheckedIndexedAccess',
+ 'noUnusedLocals',
+ 'noUnusedParameters',
+ 'useUnknownInCatchVariables',
+ /* Emit */
+ 'declaration',
+ 'declarationDir',
+ 'declarationMap',
+ 'downlevelIteration',
+ 'emitBOM',
+ 'emitDeclarationOnly',
+ 'importHelpers',
+ 'importsNotUsedAsValues',
+ 'inlineSourceMap',
+ 'inlineSources',
+ 'mapRoot',
+ 'newLine',
+ 'noEmit',
+ 'noEmitHelpers',
+ 'noEmitOnError',
+ 'outDir',
+ 'outFile',
+ 'preserveConstEnums',
+ 'preserveValueImports',
+ 'removeComments',
+ 'sourceMap',
+ 'sourceRoot',
+ 'stripInternal',
+ /* Interop Constraints */
+ 'allowSyntheticDefaultImports',
+ 'esModuleInterop',
+ 'forceConsistentCasingInFileNames',
+ 'isolatedModules',
+ 'preserveSymlinks',
+ 'verbatimModuleSyntax',
+ /* Completeness */
+ 'skipDefaultLibCheck',
+ 'skipLibCheck',
+ ],
+ pathPattern: '^compilerOptions$',
+ },
+ ],
+ },
+ };
+}
diff --git a/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/node.ts b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/node.ts
new file mode 100644
index 0000000..a79083e
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/node.ts
@@ -0,0 +1,81 @@
+import type { Linter } from 'eslint';
+
+import { interopDefault } from '../util';
+
+export async function node(): Promise {
+ const pluginNode = await interopDefault(import('eslint-plugin-n'));
+
+ return [
+ {
+ plugins: {
+ n: pluginNode,
+ },
+ rules: {
+ 'n/handle-callback-err': ['error', '^(err|error)$'],
+ 'n/no-deprecated-api': 'error',
+ 'n/no-extraneous-import': [
+ 'error',
+ {
+ allowModules: [
+ 'tsdown',
+ 'unplugin-vue',
+ '@vben/vite-config',
+ 'vitest',
+ 'vite',
+ '@vue/test-utils',
+ '@playwright/test',
+ ],
+ },
+ ],
+ // 'n/no-unpublished-import': 'off',
+ 'n/no-unsupported-features/es-syntax': [
+ 'error',
+ {
+ ignores: [],
+ version: '>=22.18.0',
+ },
+ ],
+ 'n/prefer-global/buffer': ['error', 'never'],
+ // 'n/no-missing-import': 'off',
+ 'n/prefer-global/process': ['error', 'never'],
+ 'n/process-exit-as-throw': 'error',
+ },
+ },
+ {
+ files: [
+ '**/__tests__/**/*.?([cm])[jt]s?(x)',
+ '**/*.spec.?([cm])[jt]s?(x)',
+ '**/*.test.?([cm])[jt]s?(x)',
+ '**/*.bench.?([cm])[jt]s?(x)',
+ '**/*.benchmark.?([cm])[jt]s?(x)',
+ ],
+ rules: {
+ 'n/prefer-global/process': 'off',
+ },
+ },
+ {
+ files: ['apps/backend-mock/**/**', 'docs/**/**'],
+ rules: {
+ 'n/no-extraneous-import': 'off',
+ 'n/prefer-global/buffer': 'off',
+ 'n/prefer-global/process': 'off',
+ },
+ },
+ {
+ files: ['**/**/playwright.config.ts'],
+ rules: {
+ 'n/prefer-global/buffer': 'off',
+ 'n/prefer-global/process': 'off',
+ },
+ },
+ {
+ files: [
+ 'scripts/**/*.?([cm])[jt]s?(x)',
+ 'internal/**/*.?([cm])[jt]s?(x)',
+ ],
+ rules: {
+ 'n/prefer-global/process': 'off',
+ },
+ },
+ ];
+}
diff --git a/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/perfectionist.ts b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/perfectionist.ts
new file mode 100644
index 0000000..cb8c98c
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/perfectionist.ts
@@ -0,0 +1,105 @@
+import type { Linter } from 'eslint';
+
+import { interopDefault } from '../util';
+
+export async function perfectionist(): Promise {
+ const perfectionistPlugin = await interopDefault(
+ import('eslint-plugin-perfectionist'),
+ );
+
+ return [
+ perfectionistPlugin.configs['recommended-natural'],
+ {
+ rules: {
+ 'perfectionist/sort-exports': [
+ 'error',
+ {
+ order: 'asc',
+ type: 'natural',
+ },
+ ],
+ 'perfectionist/sort-imports': [
+ 'error',
+ {
+ customGroups: [
+ {
+ selector: 'type',
+ groupName: 'vben-core-type',
+ elementNamePattern: '^@vben-core/.+',
+ },
+ {
+ selector: 'type',
+ groupName: 'vben-type',
+ elementNamePattern: '^@vben/.+',
+ },
+ {
+ selector: 'type',
+ groupName: 'vue-type',
+ elementNamePattern: ['^vue$', '^vue-.+', '^@vue/.+'],
+ },
+ {
+ groupName: 'vben',
+ elementNamePattern: '^@vben/.+',
+ },
+ {
+ groupName: 'vben-core',
+ elementNamePattern: '^@vben-core/.+',
+ },
+ {
+ groupName: 'vue',
+ elementNamePattern: ['^vue$', '^vue-.+', '^@vue/.+'],
+ },
+ ],
+ environment: 'node',
+ groups: [
+ ['type-external', 'type-builtin', 'type-import'],
+ 'vue-type',
+ 'vben-type',
+ 'vben-core-type',
+ ['type-parent', 'type-sibling', 'type-index'],
+ ['type-internal'],
+ 'value-builtin',
+ 'vue',
+ 'vben',
+ 'vben-core',
+ 'value-external',
+ 'value-internal',
+ ['value-parent', 'value-sibling', 'value-index'],
+ 'side-effect',
+ 'side-effect-style',
+ 'style',
+ 'ts-equals-import',
+ 'unknown',
+ ],
+ internalPattern: ['^#/.+'],
+ newlinesBetween: 1,
+ order: 'asc',
+ type: 'natural',
+ },
+ ],
+ 'perfectionist/sort-modules': 'off',
+ 'perfectionist/sort-named-exports': [
+ 'error',
+ {
+ order: 'asc',
+ type: 'natural',
+ },
+ ],
+ 'perfectionist/sort-objects': [
+ 'off',
+ {
+ customGroups: {
+ items: 'items',
+ list: 'list',
+ children: 'children',
+ },
+ groups: ['unknown', 'items', 'list', 'children'],
+ ignorePattern: ['children'],
+ order: 'asc',
+ type: 'natural',
+ },
+ ],
+ },
+ },
+ ];
+}
diff --git a/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/pnpm.ts b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/pnpm.ts
new file mode 100644
index 0000000..5fe5afb
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/pnpm.ts
@@ -0,0 +1,38 @@
+import type { Linter } from 'eslint';
+
+import { interopDefault } from '../util';
+
+export async function pnpm(): Promise {
+ const [pluginPnpm, parserPnpm] = await Promise.all([
+ interopDefault(import('eslint-plugin-pnpm')),
+ interopDefault(import('yaml-eslint-parser')),
+ ] as const);
+
+ return [
+ {
+ files: ['package.json', '**/package.json'],
+ language: 'jsonc/x',
+ plugins: {
+ pnpm: pluginPnpm,
+ },
+ rules: {
+ 'pnpm/json-enforce-catalog': 'error',
+ 'pnpm/json-prefer-workspace-settings': 'error',
+ 'pnpm/json-valid-catalog': 'error',
+ },
+ },
+ {
+ files: ['pnpm-workspace.yaml'],
+ languageOptions: {
+ parser: parserPnpm,
+ },
+ plugins: {
+ pnpm: pluginPnpm,
+ },
+ rules: {
+ 'pnpm/yaml-no-duplicate-catalog-item': 'error',
+ 'pnpm/yaml-no-unused-catalog-item': 'error',
+ },
+ },
+ ];
+}
diff --git a/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/typescript.ts b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/typescript.ts
new file mode 100644
index 0000000..2f1e216
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/lint-configs/eslint-config/src/configs/typescript.ts
@@ -0,0 +1,44 @@
+import type { Linter } from 'eslint';
+
+import { interopDefault } from '../util';
+
+/**
+ * @typescript-eslint 的规则已迁移到 oxlint(typescript 插件)。
+ * 这里仅保留 TS 解析器,供其它 eslint 插件(perfectionist、n 等)解析 TS/TSX 文件。
+ * 因不再有类型感知规则,已移除 parserOptions.project,eslint 解析更快。
+ *
+ * 注意:移除 @typescript-eslint 插件后,unused-imports/no-unused-vars 会退回核心实现,
+ * 无法识别 TS 类型签名里的形参(会误报)。故对 TS/TSX/Vue 统一关闭该规则,
+ * 未使用变量改由 oxlint 的 no-unused-vars(类型感知)负责。
+ */
+export async function typescript(): Promise {
+ const parserTs = await interopDefault(import('@typescript-eslint/parser'));
+
+ return [
+ {
+ files: ['**/*.?([cm])[jt]s?(x)'],
+ languageOptions: {
+ parser: parserTs,
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ ecmaVersion: 'latest',
+ extraFileExtensions: ['.vue'],
+ jsxPragma: 'React',
+ sourceType: 'module',
+ },
+ },
+ rules: {
+ 'unused-imports/no-unused-vars': 'off',
+ },
+ },
+ {
+ // Vue `
+`;
+
+ if (!loadingHtml) {
+ return;
+ }
+
+ return {
+ enforce: 'pre',
+ name: 'vite:inject-app-loading',
+ transformIndexHtml: {
+ handler(html) {
+ const re = //;
+ html = html.replace(re, `${injectScript}${loadingHtml}`);
+ return html;
+ },
+ order: 'pre',
+ },
+ };
+}
+
+/**
+ * 用于获取loading的html模板
+ */
+async function getLoadingRawByHtmlTemplate(loadingTemplate: string) {
+ // 支持在app内自定义loading模板,模版参考default-loading.html即可
+ let appLoadingPath = join(process.cwd(), loadingTemplate);
+
+ if (!fs.existsSync(appLoadingPath)) {
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
+ appLoadingPath = join(__dirname, './default-loading.html');
+ }
+
+ return await fsp.readFile(appLoadingPath, 'utf8');
+}
+
+export { viteInjectAppLoadingPlugin };
diff --git a/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/inject-metadata.ts b/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/inject-metadata.ts
new file mode 100644
index 0000000..41c4db4
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/inject-metadata.ts
@@ -0,0 +1,111 @@
+import type { PluginOption } from 'vite';
+
+import {
+ dateUtil,
+ findMonorepoRoot,
+ getPackages,
+ readPackageJSON,
+} from '@vben/node-utils';
+
+import { readWorkspaceManifest } from '@pnpm/workspace.read-manifest';
+
+function resolvePackageVersion(
+ pkgsMeta: Record,
+ name: string,
+ value: string,
+ catalog: Record,
+) {
+ if (value.includes('catalog:')) {
+ return catalog[name];
+ }
+
+ if (value.includes('workspace')) {
+ return pkgsMeta[name];
+ }
+
+ return value;
+}
+
+async function resolveMonorepoDependencies() {
+ const { packages } = await getPackages();
+ const manifest = await readWorkspaceManifest(findMonorepoRoot());
+ const catalog = manifest?.catalog || {};
+
+ const resultDevDependencies: Record = {};
+ const resultDependencies: Record = {};
+ const pkgsMeta: Record = {};
+
+ for (const { packageJson } of packages) {
+ pkgsMeta[packageJson.name] = packageJson.version;
+ }
+
+ for (const { packageJson } of packages) {
+ const { dependencies = {}, devDependencies = {} } = packageJson;
+ for (const [key, value] of Object.entries(dependencies)) {
+ resultDependencies[key] = resolvePackageVersion(
+ pkgsMeta,
+ key,
+ value,
+ catalog,
+ );
+ }
+ for (const [key, value] of Object.entries(devDependencies)) {
+ resultDevDependencies[key] = resolvePackageVersion(
+ pkgsMeta,
+ key,
+ value,
+ catalog,
+ );
+ }
+ }
+ return {
+ dependencies: resultDependencies,
+ devDependencies: resultDevDependencies,
+ };
+}
+
+/**
+ * 用于注入项目信息
+ */
+async function viteMetadataPlugin(
+ root = process.cwd(),
+): Promise {
+ const { author, description, homepage, license, version } =
+ await readPackageJSON(root);
+
+ const buildTime = dateUtil().format('YYYY-MM-DD HH:mm:ss');
+
+ return {
+ async config() {
+ const { dependencies, devDependencies } =
+ await resolveMonorepoDependencies();
+
+ const isAuthorObject = typeof author === 'object';
+ const authorName = isAuthorObject ? author.name : author;
+ const authorEmail = isAuthorObject ? author.email : null;
+ const authorUrl = isAuthorObject ? author.url : null;
+
+ return {
+ define: {
+ __VBEN_ADMIN_METADATA__: JSON.stringify({
+ authorEmail,
+ authorName,
+ authorUrl,
+ buildTime,
+ dependencies,
+ description,
+ devDependencies,
+ homepage,
+ license,
+ version,
+ }),
+ 'import.meta.env.VITE_APP_VERSION': JSON.stringify(version),
+ },
+ };
+ },
+ enforce: 'post',
+ name: 'vite:inject-metadata',
+ };
+}
+
+export { viteMetadataPlugin };
diff --git a/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/license.ts b/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/license.ts
new file mode 100644
index 0000000..7733288
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/license.ts
@@ -0,0 +1,55 @@
+import type { PluginOption } from 'vite';
+
+import { EOL } from 'node:os';
+
+import { dateUtil, readPackageJSON } from '@vben/node-utils';
+
+/**
+ * 用于注入版权信息
+ * @returns
+ */
+async function viteLicensePlugin(
+ root = process.cwd(),
+): Promise {
+ const {
+ description = '',
+ homepage = '',
+ version = '',
+ } = await readPackageJSON(root);
+
+ return {
+ apply: 'build',
+ enforce: 'post',
+ generateBundle: {
+ handler(_options, bundle) {
+ const date = dateUtil().format('YYYY-MM-DD ');
+ const copyrightText = `/*!
+ * Vben Admin
+ * Version: ${version}
+ * Author: vben
+ * Copyright (C) 2024 Vben
+ * License: MIT License
+ * Description: ${description}
+ * Date Created: ${date}
+ * Homepage: ${homepage}
+ * Contact: ann.vben@gmail.com
+*/
+ `.trim();
+
+ for (const [, fileContent] of Object.entries(bundle)) {
+ if (fileContent.type === 'chunk' && fileContent.isEntry) {
+ // 插入版权信息
+ const content = fileContent.code;
+ const updatedContent = `${copyrightText}${EOL}${content}`;
+ // 更新bundle
+ fileContent.code = updatedContent;
+ }
+ }
+ },
+ order: 'post',
+ },
+ name: 'vite:license',
+ };
+}
+
+export { viteLicensePlugin };
diff --git a/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/nitro-mock.ts b/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/nitro-mock.ts
new file mode 100644
index 0000000..60d7327
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/nitro-mock.ts
@@ -0,0 +1,98 @@
+import type { PluginOption } from 'vite';
+
+import type { NitroMockPluginOptions } from '../typing';
+
+import { colors, consola, getPackage } from '@vben/node-utils';
+
+import getPort from 'get-port';
+import { build, createDevServer, createNitro, prepare } from 'nitropack';
+
+const hmrKeyRe = /^runtimeConfig\.|routeRules\./;
+
+export const viteNitroMockPlugin = ({
+ mockServerPackage = '@vben/backend-mock',
+ port = 5320,
+ verbose = true,
+}: NitroMockPluginOptions = {}): PluginOption => {
+ return {
+ async configureServer(server) {
+ const availablePort = await getPort({ port });
+ if (availablePort !== port) {
+ return;
+ }
+
+ const pkg = await getPackage(mockServerPackage);
+ if (!pkg) {
+ consola.log(
+ `Package ${mockServerPackage} not found. Skip mock server.`,
+ );
+ return;
+ }
+
+ runNitroServer(pkg.dir, port, verbose);
+
+ const _printUrls = server.printUrls;
+ server.printUrls = () => {
+ _printUrls();
+
+ consola.log(
+ ` ${colors.green('➜')} ${colors.bold('Nitro Mock Server')}: ${colors.cyan(`http://localhost:${port}/api`)}`,
+ );
+ };
+ },
+ enforce: 'pre',
+ name: 'vite:mock-server',
+ };
+};
+
+async function runNitroServer(rootDir: string, port: number, verbose: boolean) {
+ let nitro: any;
+ const reload = async () => {
+ if (nitro) {
+ consola.info('Restarting dev server...');
+ if ('unwatch' in nitro.options._c12) {
+ await nitro.options._c12.unwatch();
+ }
+ await nitro.close();
+ }
+ nitro = await createNitro(
+ {
+ dev: true,
+ preset: 'nitro-dev',
+ rootDir,
+ },
+ {
+ c12: {
+ async onUpdate({ getDiff, newConfig }) {
+ const diff = getDiff();
+ if (diff.length === 0) {
+ return;
+ }
+ verbose &&
+ consola.info(
+ `Nitro config updated:\n${diff
+ .map((entry) => ` ${entry.toString()}`)
+ .join('\n')}`,
+ );
+ await (diff.every((e) => hmrKeyRe.test(e.key))
+ ? nitro.updateConfig(newConfig.config)
+ : reload());
+ },
+ },
+ watch: true,
+ },
+ );
+ nitro.hooks.hookOnce('restart', reload);
+
+ const server = createDevServer(nitro);
+ await server.listen(port, { showURL: false });
+ await prepare(nitro);
+ await build(nitro);
+
+ if (verbose) {
+ console.log('');
+ consola.success(colors.bold(colors.green('Nitro Mock Server started.')));
+ }
+ };
+ return await reload();
+}
diff --git a/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/print.ts b/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/print.ts
new file mode 100644
index 0000000..0146b8a
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/print.ts
@@ -0,0 +1,28 @@
+import type { PluginOption } from 'vite';
+
+import type { PrintPluginOptions } from '../typing';
+
+import { colors } from '@vben/node-utils';
+
+export const vitePrintPlugin = (
+ options: PrintPluginOptions = {},
+): PluginOption => {
+ const { infoMap = {} } = options;
+
+ return {
+ configureServer(server) {
+ const _printUrls = server.printUrls;
+ server.printUrls = () => {
+ _printUrls();
+
+ for (const [key, value] of Object.entries(infoMap)) {
+ console.log(
+ ` ${colors.green('➜')} ${colors.bold(key)}: ${colors.cyan(value)}`,
+ );
+ }
+ };
+ },
+ enforce: 'pre',
+ name: 'vite:print-info',
+ };
+};
diff --git a/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/tailwind-reference.ts b/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/tailwind-reference.ts
new file mode 100644
index 0000000..8646049
--- /dev/null
+++ b/deploy/fba/fba-ui-src/internal/vite-config/src/plugins/tailwind-reference.ts
@@ -0,0 +1,40 @@
+import type { Plugin } from 'vite';
+
+const REFERENCE_LINE = '@reference "@vben/tailwind-config/theme";\n';
+
+/**
+ * Auto-inject @reference into Vue SFC `;
+
+ // 要更新的CSS变量和它们的新值
+ const updatedVariables = {
+ fontSize: '16px',
+ primaryColor: 'blue',
+ secondaryColor: 'green',
+ };
+
+ // 调用函数来更新CSS变量
+ updateCSSVariables(updatedVariables, 'custom-styles');
+
+ // 获取更新后的样式内容
+ const styleElement = document.querySelector('#custom-styles');
+ const updatedStyleContent = styleElement ? styleElement.textContent : '';
+
+ // 检查更新后的样式内容是否包含正确的更新值
+ expect(
+ updatedStyleContent?.includes('primaryColor: blue;') &&
+ updatedStyleContent?.includes('secondaryColor: green;') &&
+ updatedStyleContent?.includes('fontSize: 16px;'),
+ ).toBe(true);
+});
+
+it('updateCSSVariables should support a custom selector', () => {
+ document.head.innerHTML = ``;
+
+ // 使用自定义选择器(如 TDesign 的 theme-mode 选择器)更新 CSS 变量
+ updateCSSVariables(
+ { '--td-brand-color': 'rgb(0, 82, 217)' },
+ 'tdesign-styles',
+ ":root[theme-mode='dark']",
+ );
+
+ const styleElement = document.querySelector('#tdesign-styles');
+ const content = styleElement?.textContent ?? '';
+
+ // 选择器与变量都应正确写入
+ expect(content.startsWith(":root[theme-mode='dark'] {")).toBe(true);
+ expect(content.includes('--td-brand-color: rgb(0, 82, 217);')).toBe(true);
+});
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/__tests__/util.test.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/__tests__/util.test.ts
new file mode 100644
index 0000000..ccdeb96
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/__tests__/util.test.ts
@@ -0,0 +1,158 @@
+import { describe, expect, it } from 'vitest';
+
+import { bindMethods, getNestedValue } from '../util';
+
+class TestClass {
+ public value: string;
+
+ constructor(value: string) {
+ this.value = value;
+ bindMethods(this); // 调用通用方法
+ }
+
+ getValue() {
+ return this.value;
+ }
+
+ setValue(newValue: string) {
+ this.value = newValue;
+ }
+}
+
+describe('bindMethods', () => {
+ it('should bind methods to the instance correctly', () => {
+ const instance = new TestClass('initial');
+
+ // 解构方法
+ const { getValue } = instance;
+
+ // 检查 getValue 是否能正确调用,并且 this 绑定了 instance
+ expect(getValue()).toBe('initial');
+ });
+
+ it('should bind multiple methods', () => {
+ const instance = new TestClass('initial');
+
+ const { getValue, setValue } = instance;
+
+ // 检查 getValue 和 setValue 方法是否正确绑定了 this
+ setValue('newValue');
+ expect(getValue()).toBe('newValue');
+ });
+
+ it('should not bind non-function properties', () => {
+ const instance = new TestClass('initial');
+
+ // 检查普通属性是否保持原样
+ expect(instance.value).toBe('initial');
+ });
+
+ it('should not bind constructor method', () => {
+ const instance = new TestClass('test');
+
+ // 检查 constructor 是否没有被绑定
+ expect(instance.constructor.name).toBe('TestClass');
+ });
+
+ it('should not bind getter/setter properties', () => {
+ class TestWithGetterSetter {
+ get value() {
+ return this._value;
+ }
+
+ set value(newValue: string) {
+ this._value = newValue;
+ }
+
+ private _value: string = 'test';
+
+ constructor() {
+ bindMethods(this);
+ }
+ }
+
+ const instance = new TestWithGetterSetter();
+ const { value } = instance;
+
+ // Getter 和 setter 不应被绑定
+ expect(value).toBe('test');
+ });
+});
+
+describe('getNestedValue', () => {
+ interface UserProfile {
+ age: number;
+ name: string;
+ }
+
+ interface UserSettings {
+ theme: string;
+ }
+
+ interface Data {
+ user: {
+ profile: UserProfile;
+ settings: UserSettings;
+ };
+ }
+
+ const data: Data = {
+ user: {
+ profile: {
+ age: 25,
+ name: 'Alice',
+ },
+ settings: {
+ theme: 'dark',
+ },
+ },
+ };
+
+ it('should get a nested value when the path is valid', () => {
+ const result = getNestedValue(data, 'user.profile.name');
+ expect(result).toBe('Alice');
+ });
+
+ it('should return undefined for non-existent property', () => {
+ const result = getNestedValue(data, 'user.profile.gender');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined when accessing a non-existent deep path', () => {
+ const result = getNestedValue(data, 'user.nonexistent.field');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined if a middle level is undefined', () => {
+ const result = getNestedValue({ user: undefined }, 'user.profile.name');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return the correct value for a nested setting', () => {
+ const result = getNestedValue(data, 'user.settings.theme');
+ expect(result).toBe('dark');
+ });
+
+ it('should work for a single-level path', () => {
+ const result = getNestedValue({ a: 1, b: 2 }, 'b');
+ expect(result).toBe(2);
+ });
+
+ 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', () => {
+ const complexData = { list: [{ name: 'Item1' }, { name: 'Item2' }] };
+ const result = getNestedValue(complexData, 'list.1.name');
+ expect(result).toBe('Item2');
+ });
+
+ it('should return undefined when accessing an out-of-bounds array index', () => {
+ const complexData = { list: [{ name: 'Item1' }] };
+ const result = getNestedValue(complexData, 'list.2.name');
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/__tests__/window.test.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/__tests__/window.test.ts
new file mode 100644
index 0000000..ebb04bb
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/__tests__/window.test.ts
@@ -0,0 +1,33 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { openWindow } from '../window';
+
+describe('openWindow', () => {
+ // 保存原始的 window.open 函数
+ let originalOpen: typeof window.open;
+
+ beforeEach(() => {
+ originalOpen = window.open;
+ });
+
+ afterEach(() => {
+ window.open = originalOpen;
+ });
+
+ it('should call window.open with correct arguments', () => {
+ const url = 'https://example.com';
+ const options = { noopener: true, noreferrer: true, target: '_blank' };
+
+ window.open = vi.fn();
+
+ // 调用函数
+ openWindow(url, options);
+
+ // 验证 window.open 是否被正确地调用
+ expect(window.open).toHaveBeenCalledWith(
+ url,
+ options.target,
+ 'noopener=yes,noreferrer=yes',
+ );
+ });
+});
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/cn.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/cn.ts
new file mode 100644
index 0000000..3a2f977
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/cn.ts
@@ -0,0 +1,10 @@
+import type { ClassValue } from 'clsx';
+
+import { clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export { cn };
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/date.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/date.ts
new file mode 100644
index 0000000..784b977
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/date.ts
@@ -0,0 +1,78 @@
+import dayjs from 'dayjs';
+import timezone from 'dayjs/plugin/timezone.js';
+import utc from 'dayjs/plugin/utc.js';
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+type FormatDate = Date | dayjs.Dayjs | number | string;
+
+type Format =
+ | 'HH'
+ | 'HH:mm'
+ | 'HH:mm:ss'
+ | 'YYYY'
+ | 'YYYY-MM'
+ | 'YYYY-MM-DD'
+ | 'YYYY-MM-DD HH'
+ | 'YYYY-MM-DD HH:mm'
+ | 'YYYY-MM-DD HH:mm:ss'
+ | (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()) {
+ throw new Error('Invalid date');
+ }
+ return date.tz().format(format);
+ } catch (error) {
+ console.error(`Error formatting date: ${error}`);
+ return String(time ?? '');
+ }
+}
+
+export function formatDateTime(time?: FormatDate) {
+ return formatDate(time, 'YYYY-MM-DD HH:mm:ss');
+}
+
+export function isDate(value: any): value is Date {
+ return value instanceof Date;
+}
+
+export function isDayjsObject(value: any): value is dayjs.Dayjs {
+ return dayjs.isDayjs(value);
+}
+
+/**
+ * 获取当前时区
+ * @returns 当前时区
+ */
+export const getSystemTimezone = () => {
+ return dayjs.tz.guess();
+};
+
+/**
+ * 自定义设置的时区
+ */
+let currentTimezone = getSystemTimezone();
+
+/**
+ * 设置默认时区
+ * @param timezone
+ */
+export const setCurrentTimezone = (timezone?: string) => {
+ currentTimezone = timezone || getSystemTimezone();
+ dayjs.tz.setDefault(currentTimezone);
+};
+
+/**
+ * 获取设置的时区
+ * @returns 设置的时区
+ */
+export const getCurrentTimezone = () => {
+ return currentTimezone;
+};
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/diff.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/diff.ts
new file mode 100644
index 0000000..449214d
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/diff.ts
@@ -0,0 +1,96 @@
+// type Diff = T;
+
+// 比较两个数组是否相等
+
+function arraysEqual(a: T[], b: T[]): boolean {
+ if (a.length !== b.length) return false;
+ const counter = new Map();
+ for (const value of a) {
+ counter.set(value, (counter.get(value) || 0) + 1);
+ }
+ for (const value of b) {
+ const count = counter.get(value);
+ if (count === undefined || count === 0) {
+ return false;
+ }
+ counter.set(value, count - 1);
+ }
+ return true;
+}
+
+// 深度对比两个值
+// function deepEqual(oldVal: T, newVal: T): boolean {
+// if (
+// typeof oldVal === 'object' &&
+// oldVal !== null &&
+// typeof newVal === 'object' &&
+// newVal !== null
+// ) {
+// return Array.isArray(oldVal) && Array.isArray(newVal)
+// ? arraysEqual(oldVal, newVal)
+// : diff(oldVal as any, newVal as any) === null;
+// } else {
+// return oldVal === newVal;
+// }
+// }
+
+// // diff 函数
+// function diff(
+// oldObj: T,
+// newObj: T,
+// ignoreFields: (keyof T)[] = [],
+// ): { [K in keyof T]?: Diff } | null {
+// const difference: { [K in keyof T]?: Diff } = {};
+
+// for (const key in oldObj) {
+// if (ignoreFields.includes(key)) continue;
+// const oldValue = oldObj[key];
+// const newValue = newObj[key];
+
+// if (!deepEqual(oldValue, newValue)) {
+// difference[key] = newValue;
+// }
+// }
+
+// return Object.keys(difference).length === 0 ? null : difference;
+// }
+
+type DiffResult = Partial<{
+ [K in keyof T]: T[K] extends object ? DiffResult : T[K];
+}>;
+
+function diff>(obj1: T, obj2: T): DiffResult {
+ function findDifferences(o1: any, o2: any): any {
+ if (Array.isArray(o1) && Array.isArray(o2)) {
+ if (!arraysEqual(o1, o2)) {
+ return o2;
+ }
+ return undefined;
+ }
+
+ if (
+ typeof o1 === 'object' &&
+ typeof o2 === 'object' &&
+ o1 !== null &&
+ o2 !== null
+ ) {
+ const diffResult: any = {};
+
+ const keys = new Set([...Object.keys(o1), ...Object.keys(o2)]);
+ keys.forEach((key) => {
+ const valueDiff = findDifferences(o1[key], o2[key]);
+ if (valueDiff !== undefined) {
+ diffResult[key] = valueDiff;
+ }
+ });
+
+ return Object.keys(diffResult).length > 0 ? diffResult : undefined;
+ }
+
+ return o1 === o2 ? undefined : o2;
+ }
+
+ return findDifferences(obj1, obj2);
+}
+
+export { arraysEqual, diff };
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/dom.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/dom.ts
new file mode 100644
index 0000000..35a7e5f
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/dom.ts
@@ -0,0 +1,107 @@
+export interface VisibleDomRect {
+ bottom: number;
+ height: number;
+ left: number;
+ right: number;
+ top: number;
+ width: number;
+}
+
+/**
+ * 获取元素可见信息
+ * @param element
+ */
+export function getElementVisibleRect(
+ element?: HTMLElement | null | undefined,
+): VisibleDomRect {
+ if (!element) {
+ return {
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ width: 0,
+ };
+ }
+ const rect = element.getBoundingClientRect();
+ const viewHeight = Math.max(
+ document.documentElement.clientHeight,
+ window.innerHeight,
+ );
+
+ const top = Math.max(rect.top, 0);
+ const bottom = Math.min(rect.bottom, viewHeight);
+
+ const viewWidth = Math.max(
+ document.documentElement.clientWidth,
+ window.innerWidth,
+ );
+
+ const left = Math.max(rect.left, 0);
+ const right = Math.min(rect.right, viewWidth);
+
+ // 如果元素完全不可见,则返回一个空的矩形
+ if (top >= viewHeight || bottom <= 0 || left >= viewWidth || right <= 0) {
+ return {
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ width: 0,
+ };
+ }
+
+ return {
+ bottom,
+ height: Math.max(0, bottom - top),
+ left,
+ right,
+ top,
+ width: Math.max(0, right - left),
+ };
+}
+
+export function getScrollbarWidth() {
+ const scrollDiv = document.createElement('div');
+
+ scrollDiv.style.visibility = 'hidden';
+ scrollDiv.style.overflow = 'scroll';
+ scrollDiv.style.position = 'absolute';
+ scrollDiv.style.top = '-9999px';
+
+ document.body.append(scrollDiv);
+
+ const innerDiv = document.createElement('div');
+ scrollDiv.append(innerDiv);
+
+ const scrollbarWidth = scrollDiv.offsetWidth - innerDiv.offsetWidth;
+
+ scrollDiv.remove();
+ return scrollbarWidth;
+}
+
+export function needsScrollbar() {
+ const doc = document.documentElement;
+ const body = document.body;
+
+ // 检查 body 的 overflow-y 样式
+ const overflowY = window.getComputedStyle(body).overflowY;
+
+ // 如果明确设置了需要滚动条的样式
+ if (overflowY === 'scroll' || overflowY === 'auto') {
+ return doc.scrollHeight > window.innerHeight;
+ }
+
+ // 在其他情况下,根据 scrollHeight 和 innerHeight 比较判断
+ return doc.scrollHeight > window.innerHeight;
+}
+
+export function triggerWindowResize(): void {
+ // 创建一个新的 resize 事件
+ const resizeEvent = new Event('resize');
+
+ // 触发 window 的 resize 事件
+ window.dispatchEvent(resizeEvent);
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/download.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/download.ts
new file mode 100644
index 0000000..6f38ee5
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/download.ts
@@ -0,0 +1,157 @@
+import { openWindow } from './window';
+
+interface DownloadOptions {
+ fileName?: string;
+ source: T;
+ target?: string;
+}
+
+const DEFAULT_FILENAME = 'downloaded_file';
+
+/**
+ * 通过 URL 下载文件,支持跨域
+ * @throws {Error} - 当下载失败时抛出错误
+ */
+export async function downloadFileFromUrl({
+ fileName,
+ source,
+ target = '_blank',
+}: DownloadOptions): Promise {
+ if (!source || typeof source !== 'string') {
+ throw new Error('Invalid URL.');
+ }
+
+ const isChrome = window.navigator.userAgent.toLowerCase().includes('chrome');
+ const isSafari = window.navigator.userAgent.toLowerCase().includes('safari');
+
+ if (/iP/.test(window.navigator.userAgent)) {
+ console.error('Your browser does not support download!');
+ return;
+ }
+
+ if (isChrome || isSafari) {
+ triggerDownload(source, resolveFileName(source, fileName));
+ return;
+ }
+ if (!source.includes('?')) {
+ source += '?download';
+ }
+
+ openWindow(source, { target });
+}
+
+/**
+ * 通过 Base64 下载文件
+ */
+export function downloadFileFromBase64({ fileName, source }: DownloadOptions) {
+ if (!source || typeof source !== 'string') {
+ throw new Error('Invalid Base64 data.');
+ }
+
+ const resolvedFileName = fileName || DEFAULT_FILENAME;
+ triggerDownload(source, resolvedFileName);
+}
+
+/**
+ * 通过图片 URL 下载图片文件
+ */
+export async function downloadFileFromImageUrl({
+ fileName,
+ source,
+}: DownloadOptions) {
+ const base64 = await urlToBase64(source);
+ downloadFileFromBase64({ fileName, source: base64 });
+}
+
+/**
+ * 通过 Blob 下载文件
+ */
+export function downloadFileFromBlob({
+ fileName = DEFAULT_FILENAME,
+ source,
+}: DownloadOptions): void {
+ if (!(source instanceof Blob)) {
+ throw new TypeError('Invalid Blob data.');
+ }
+
+ const url = URL.createObjectURL(source);
+ triggerDownload(url, fileName);
+}
+
+/**
+ * 下载文件,支持 Blob、字符串和其他 BlobPart 类型
+ */
+export function downloadFileFromBlobPart({
+ fileName = DEFAULT_FILENAME,
+ source,
+}: DownloadOptions): void {
+ // 如果 data 不是 Blob,则转换为 Blob
+ const blob =
+ source instanceof Blob
+ ? source
+ : new Blob([source], { type: 'application/octet-stream' });
+
+ // 创建对象 URL 并触发下载
+ const url = URL.createObjectURL(blob);
+ triggerDownload(url, fileName);
+}
+
+/**
+ * img url to base64
+ * @param url
+ */
+export function urlToBase64(url: string, mineType?: string): Promise {
+ return new Promise((resolve, reject) => {
+ let canvas = document.createElement('CANVAS') as HTMLCanvasElement | null;
+ const ctx = canvas?.getContext('2d');
+ const img = new Image();
+ img.crossOrigin = '';
+ img.addEventListener('load', () => {
+ if (!canvas || !ctx) {
+ return reject(new Error('Failed to create canvas.'));
+ }
+ canvas.height = img.height;
+ canvas.width = img.width;
+ ctx.drawImage(img, 0, 0);
+ const dataURL = canvas.toDataURL(mineType || 'image/png');
+ canvas = null;
+ resolve(dataURL);
+ });
+ img.src = url;
+ });
+}
+
+/**
+ * 通用下载触发函数
+ * @param href - 文件下载的 URL
+ * @param fileName - 下载文件的名称,如果未提供则自动识别
+ * @param revokeDelay - 清理 URL 的延迟时间 (毫秒)
+ */
+export function triggerDownload(
+ href: string,
+ fileName: string | undefined,
+ revokeDelay: number = 100,
+): void {
+ const defaultFileName = 'downloaded_file';
+ const finalFileName = fileName || defaultFileName;
+
+ const link = document.createElement('a');
+ link.href = href;
+ link.download = finalFileName;
+ link.style.display = 'none';
+
+ if (link.download === undefined) {
+ link.setAttribute('target', '_blank');
+ }
+
+ document.body.append(link);
+ link.click();
+ link.remove();
+
+ // 清理临时 URL 以释放内存
+ setTimeout(() => URL.revokeObjectURL(href), revokeDelay);
+}
+
+function resolveFileName(url: string, fileName?: string): string {
+ return fileName || url.slice(url.lastIndexOf('/') + 1) || DEFAULT_FILENAME;
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/index.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/index.ts
new file mode 100644
index 0000000..991b741
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/index.ts
@@ -0,0 +1,21 @@
+export * from './cn';
+export * from './date';
+export * from './diff';
+export * from './dom';
+export * from './download';
+export * from './inference';
+export * from './letter';
+export * from './merge';
+export * from './nprogress';
+export * from './resources';
+export * from './stack';
+export * from './state-handler';
+export * from './to';
+export * from './tree';
+export * from './unique';
+export * from './update-css-variables';
+export * from './util';
+export * from './window';
+export { debounce, get, isEqual, set } from 'es-toolkit/compat';
+// export { cloneDeep } from 'es-toolkit/object';
+export { default as cloneDeep } from 'lodash.clonedeep';
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/inference.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/inference.ts
new file mode 100644
index 0000000..a9ace62
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/inference.ts
@@ -0,0 +1,164 @@
+import { isFunction, isObject, isString } from '@vue/shared';
+
+/**
+ * 检查传入的值是否为undefined。
+ *
+ * @param {unknown} value 要检查的值。
+ * @returns {boolean} 如果值是undefined,返回true,否则返回false。
+ */
+function isUndefined(value?: unknown): value is undefined {
+ return value === undefined;
+}
+
+/**
+ * 检查传入的值是否为boolean
+ * @param value
+ * @returns 如果值是布尔值,返回true,否则返回false。
+ */
+function isBoolean(value: unknown): value is boolean {
+ return typeof value === 'boolean';
+}
+
+/**
+ * 检查传入的值是否为空。
+ *
+ * 以下情况将被认为是空:
+ * - 值为null。
+ * - 值为undefined。
+ * - 值为一个空字符串。
+ * - 值为一个长度为0的数组。
+ * - 值为一个没有元素的Map或Set。
+ * - 值为一个没有属性的对象。
+ *
+ * @param {T} value 要检查的值。
+ * @returns {boolean} 如果值为空,返回true,否则返回false。
+ */
+function isEmpty(value?: T): value is T {
+ if (value === null || value === undefined) {
+ return true;
+ }
+
+ if (Array.isArray(value) || isString(value)) {
+ return value.length === 0;
+ }
+
+ if (value instanceof Map || value instanceof Set) {
+ return value.size === 0;
+ }
+
+ if (isObject(value)) {
+ return Object.keys(value).length === 0;
+ }
+
+ return false;
+}
+
+/**
+ * 检查传入的字符串是否为有效的HTTP或HTTPS URL。
+ *
+ * @param {string} url 要检查的字符串。
+ * @return {boolean} 如果字符串是有效的HTTP或HTTPS URL,返回true,否则返回false。
+ */
+function isHttpUrl(url?: string): boolean {
+ if (!url) {
+ return false;
+ }
+ // 使用正则表达式测试URL是否以http:// 或 https:// 开头
+ const httpRegex = /^https?:\/\/.*$/;
+ return httpRegex.test(url);
+}
+
+/**
+ * 检查传入的值是否为window对象。
+ *
+ * @param {any} value 要检查的值。
+ * @returns {boolean} 如果值是window对象,返回true,否则返回false。
+ */
+function isWindow(value: any): value is Window {
+ return (
+ typeof window !== 'undefined' && value !== null && value === value.window
+ );
+}
+
+/**
+ * 检查当前运行环境是否为Mac OS。
+ *
+ * 这个函数通过检查navigator.userAgent字符串来判断当前运行环境。
+ * 如果userAgent字符串中包含"macintosh"或"mac os x"(不区分大小写),则认为当前环境是Mac OS。
+ *
+ * @returns {boolean} 如果当前环境是Mac OS,返回true,否则返回false。
+ */
+function isMacOs(): boolean {
+ const macRegex = /macintosh|mac os x/i;
+ return macRegex.test(navigator.userAgent);
+}
+
+/**
+ * 检查当前运行环境是否为Windows OS。
+ *
+ * 这个函数通过检查navigator.userAgent字符串来判断当前运行环境。
+ * 如果userAgent字符串中包含"windows"或"win32"(不区分大小写),则认为当前环境是Windows OS。
+ *
+ * @returns {boolean} 如果当前环境是Windows OS,返回true,否则返回false。
+ */
+function isWindowsOs(): boolean {
+ const windowsRegex = /windows|win32/i;
+ return windowsRegex.test(navigator.userAgent);
+}
+
+/**
+ * 检查传入的值是否为数字
+ * @param value
+ */
+function isNumber(value: any): value is number {
+ return typeof value === 'number' && Number.isFinite(value);
+}
+
+/**
+ * Returns the first value in the provided list that is neither `null` nor `undefined`.
+ *
+ * This function iterates over the input values and returns the first one that is
+ * not strictly equal to `null` or `undefined`. If all values are either `null` or
+ * `undefined`, it returns `undefined`.
+ *
+ * @template T - The type of the input values.
+ * @param {...(T | null | undefined)[]} values - A list of values to evaluate.
+ * @returns {T | undefined} - The first value that is not `null` or `undefined`, or `undefined` if none are found.
+ *
+ * @example
+ * // Returns 42 because it is the first non-null, non-undefined value.
+ * getFirstNonNullOrUndefined(undefined, null, 42, 'hello'); // 42
+ *
+ * @example
+ * // Returns 'hello' because it is the first non-null, non-undefined value.
+ * getFirstNonNullOrUndefined(null, undefined, 'hello', 123); // 'hello'
+ *
+ * @example
+ * // Returns undefined because all values are either null or undefined.
+ * getFirstNonNullOrUndefined(undefined, null); // undefined
+ */
+function getFirstNonNullOrUndefined(
+ ...values: (null | T | undefined)[]
+): T | undefined {
+ for (const value of values) {
+ if (value !== undefined && value !== null) {
+ return value;
+ }
+ }
+ return undefined;
+}
+
+export {
+ getFirstNonNullOrUndefined,
+ isBoolean,
+ isEmpty,
+ isFunction,
+ isHttpUrl,
+ isMacOs,
+ isNumber,
+ isObject,
+ isString,
+ isUndefined,
+ isWindow,
+ isWindowsOs,
+};
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/letter.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/letter.ts
new file mode 100644
index 0000000..65a1c22
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/letter.ts
@@ -0,0 +1,47 @@
+/**
+ * 将字符串的首字母大写
+ * @param string
+ */
+function capitalizeFirstLetter(string: string): string {
+ return string.charAt(0).toUpperCase() + string.slice(1);
+}
+
+/**
+ * 将字符串的首字母转换为小写。
+ *
+ * @param str 要转换的字符串
+ * @returns 首字母小写的字符串
+ */
+function toLowerCaseFirstLetter(str: string): string {
+ if (!str) return str; // 如果字符串为空,直接返回
+ return str.charAt(0).toLowerCase() + str.slice(1);
+}
+
+/**
+ * 生成驼峰命名法的键名
+ * @param key
+ * @param parentKey
+ */
+function toCamelCase(key: string, parentKey: string): string {
+ if (!parentKey) {
+ return key;
+ }
+ return parentKey + key.charAt(0).toUpperCase() + key.slice(1);
+}
+
+function kebabToCamelCase(str: string): string {
+ return str
+ .split('-')
+ .filter(Boolean)
+ .map((word, index) =>
+ index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1),
+ )
+ .join('');
+}
+
+export {
+ capitalizeFirstLetter,
+ kebabToCamelCase,
+ toCamelCase,
+ toLowerCaseFirstLetter,
+};
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/merge.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/merge.ts
new file mode 100644
index 0000000..4bf79eb
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/merge.ts
@@ -0,0 +1,10 @@
+import { createDefu } from 'defu';
+
+export { createDefu as createMerge, defu as merge } from 'defu';
+
+export const mergeWithArrayOverride = createDefu((originObj, key, updates) => {
+ if (Array.isArray(originObj[key]) && Array.isArray(updates)) {
+ originObj[key] = updates;
+ return true;
+ }
+});
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/nprogress.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/nprogress.ts
new file mode 100644
index 0000000..8e8fe2e
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/nprogress.ts
@@ -0,0 +1,43 @@
+import type NProgress from 'nprogress';
+
+// 创建一个NProgress实例的变量,初始值为null
+let nProgressInstance: null | typeof NProgress = null;
+
+/**
+ * 动态加载NProgress库,并进行配置。
+ * 此函数首先检查是否已经加载过NProgress库,如果已经加载过,则直接返回NProgress实例。
+ * 否则,动态导入NProgress库,进行配置,然后返回NProgress实例。
+ *
+ * @returns NProgress实例的Promise对象。
+ */
+async function loadNprogress() {
+ if (nProgressInstance) {
+ return nProgressInstance;
+ }
+ nProgressInstance = await import('nprogress');
+ nProgressInstance.configure({
+ showSpinner: true,
+ speed: 300,
+ });
+ return nProgressInstance;
+}
+
+/**
+ * 开始显示进度条。
+ * 此函数首先加载NProgress库,然后调用NProgress的start方法开始显示进度条。
+ */
+async function startProgress() {
+ const nprogress = await loadNprogress();
+ nprogress?.start();
+}
+
+/**
+ * 停止显示进度条,并隐藏进度条。
+ * 此函数首先加载NProgress库,然后调用NProgress的done方法停止并隐藏进度条。
+ */
+async function stopProgress() {
+ const nprogress = await loadNprogress();
+ nprogress?.done();
+}
+
+export { startProgress, stopProgress };
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/resources.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/resources.ts
new file mode 100644
index 0000000..c5afa7f
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/resources.ts
@@ -0,0 +1,21 @@
+/**
+ * 加载js文件
+ * @param src js文件地址
+ */
+function loadScript(src: string) {
+ return new Promise((resolve, reject) => {
+ if (document.querySelector(`script[src="${src}"]`)) {
+ // 如果已经加载过,直接 resolve
+ return resolve();
+ }
+ const script = document.createElement('script');
+ script.src = src;
+ script.addEventListener('load', () => resolve());
+ script.addEventListener('error', () =>
+ reject(new Error(`Failed to load script: ${src}`)),
+ );
+ document.head.append(script);
+ });
+}
+
+export { loadScript };
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/stack.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/stack.ts
new file mode 100644
index 0000000..d8f5d4a
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/stack.ts
@@ -0,0 +1,103 @@
+/**
+ * @zh_CN 栈数据结构
+ */
+export class Stack {
+ /**
+ * @zh_CN 栈内元素数量
+ */
+ get size() {
+ return this.items.length;
+ }
+ /**
+ * @zh_CN 是否去重
+ */
+ private readonly dedup: boolean;
+ /**
+ * @zh_CN 栈内元素
+ */
+ private items: T[] = [];
+
+ /**
+ * @zh_CN 栈的最大容量
+ */
+ private readonly maxSize?: number;
+
+ constructor(dedup = true, maxSize?: number) {
+ this.maxSize = maxSize;
+ this.dedup = dedup;
+ }
+
+ /**
+ * @zh_CN 清空栈内元素
+ */
+ clear() {
+ this.items.length = 0;
+ }
+
+ /**
+ * @zh_CN 查看栈顶元素
+ * @returns 栈顶元素
+ */
+ peek(): T | undefined {
+ return this.items[this.items.length - 1];
+ }
+
+ /**
+ * @zh_CN 出栈
+ * @returns 栈顶元素
+ */
+ pop(): T | undefined {
+ return this.items.pop();
+ }
+
+ /**
+ * @zh_CN 入栈
+ * @param items 要入栈的元素
+ */
+ push(...items: T[]) {
+ items.forEach((item) => {
+ // 去重
+ if (this.dedup) {
+ const index = this.items.indexOf(item);
+ if (index !== -1) {
+ this.items.splice(index, 1);
+ }
+ }
+ this.items.push(item);
+ if (this.maxSize && this.items.length > this.maxSize) {
+ this.items.splice(0, this.items.length - this.maxSize);
+ }
+ });
+ }
+ /**
+ * @zh_CN 移除栈内元素
+ * @param itemList 要移除的元素列表
+ */
+ remove(...itemList: T[]) {
+ this.items = this.items.filter((i) => !itemList.includes(i));
+ }
+ /**
+ * @zh_CN 保留栈内元素
+ * @param itemList 要保留的元素列表
+ */
+ retain(itemList: T[]) {
+ this.items = this.items.filter((i) => itemList.includes(i));
+ }
+
+ /**
+ * @zh_CN 转换为数组
+ * @returns 栈内元素数组
+ */
+ toArray(): T[] {
+ return [...this.items];
+ }
+}
+
+/**
+ * @zh_CN 创建一个栈实例
+ * @param dedup 是否去重
+ * @param maxSize 栈的最大容量
+ * @returns 栈实例
+ */
+export const createStack = (dedup = true, maxSize?: number) =>
+ new Stack(dedup, maxSize);
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/state-handler.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/state-handler.ts
new file mode 100644
index 0000000..a8bbe4f
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/state-handler.ts
@@ -0,0 +1,50 @@
+export class StateHandler {
+ private condition: boolean = false;
+ private rejectCondition: ((reason?: Error) => void) | null = null;
+ private resolveCondition: (() => void) | null = null;
+
+ isConditionTrue(): boolean {
+ return this.condition;
+ }
+
+ reset() {
+ this.condition = false;
+ this.clearPromises();
+ }
+
+ // 触发状态为 false 时,reject
+ setConditionFalse() {
+ this.condition = false;
+ if (this.rejectCondition) {
+ this.rejectCondition(new Error('Condition was set to false'));
+ this.clearPromises();
+ }
+ }
+
+ // 触发状态为 true 时,resolve
+ setConditionTrue() {
+ this.condition = true;
+ if (this.resolveCondition) {
+ this.resolveCondition();
+ this.clearPromises();
+ }
+ }
+
+ // 返回一个 Promise,等待 condition 变为 true
+ waitForCondition(): Promise {
+ return new Promise((resolve, reject) => {
+ if (this.condition) {
+ resolve(); // 如果 condition 已经为 true,立即 resolve
+ } else {
+ this.resolveCondition = resolve;
+ this.rejectCondition = reject;
+ }
+ });
+ }
+
+ // 清理 resolve/reject 函数
+ private clearPromises() {
+ this.resolveCondition = null;
+ this.rejectCondition = null;
+ }
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/to.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/to.ts
new file mode 100644
index 0000000..6f25405
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/to.ts
@@ -0,0 +1,21 @@
+/**
+ * @param { Readonly } promise
+ * @param {object=} errorExt - Additional Information you can pass to the err object
+ * @return { Promise }
+ */
+export async function to(
+ promise: Readonly>,
+ errorExt?: object,
+): Promise<[null, T] | [U, undefined]> {
+ try {
+ const data = await promise;
+ const result: [null, T] = [null, data];
+ return result;
+ } catch (error) {
+ if (errorExt) {
+ const parsedError = Object.assign({}, error, errorExt);
+ return [parsedError as U, undefined];
+ }
+ return [error as U, undefined];
+ }
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/tree.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/tree.ts
new file mode 100644
index 0000000..f3056dc
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/tree.ts
@@ -0,0 +1,125 @@
+interface TreeConfigOptions {
+ // 子属性的名称,默认为'children'
+ childProps: string;
+}
+
+/**
+ * @zh_CN 遍历树形结构,并返回所有节点中指定的值。
+ * @param tree 树形结构数组
+ * @param getValue 获取节点值的函数
+ * @param options 作为子节点数组的可选属性名称。
+ * @returns 所有节点中指定的值的数组
+ */
+function traverseTreeValues(
+ tree: T[],
+ getValue: (node: T) => V,
+ options?: TreeConfigOptions,
+): V[] {
+ const result: V[] = [];
+ const { childProps } = options || {
+ childProps: 'children',
+ };
+
+ const dfs = (treeNode: T) => {
+ const value = getValue(treeNode);
+ result.push(value);
+ const children = (treeNode as Record)?.[childProps];
+ if (!children) {
+ return;
+ }
+ if (children.length > 0) {
+ for (const child of children) {
+ dfs(child);
+ }
+ }
+ };
+
+ for (const treeNode of tree) {
+ dfs(treeNode);
+ }
+ return result.filter(Boolean);
+}
+
+/**
+ * 根据条件过滤给定树结构的节点,并以原有顺序返回所有匹配节点的数组。
+ * @param tree 要过滤的树结构的根节点数组。
+ * @param filter 用于匹配每个节点的条件。
+ * @param options 作为子节点数组的可选属性名称。
+ * @returns 包含所有匹配节点的数组。
+ */
+function filterTree>(
+ tree: T[],
+ filter: (node: T) => boolean,
+ options?: TreeConfigOptions,
+): T[] {
+ const { childProps } = options || {
+ childProps: 'children',
+ };
+
+ const _filterTree = (nodes: T[]): T[] => {
+ return nodes.filter((node: Record) => {
+ if (filter(node as T)) {
+ if (node[childProps]) {
+ node[childProps] = _filterTree(node[childProps]);
+ }
+ return true;
+ }
+ return false;
+ });
+ };
+
+ return _filterTree(tree);
+}
+
+/**
+ * 根据条件重新映射给定树结构的节
+ * @param tree 要过滤的树结构的根节点数组。
+ * @param mapper 用于map每个节点的条件。
+ * @param options 作为子节点数组的可选属性名称。
+ */
+function mapTree>(
+ tree: T[],
+ mapper: (node: T) => V,
+ options?: TreeConfigOptions,
+): V[] {
+ const { childProps } = options || {
+ childProps: 'children',
+ };
+ return tree.map((node) => {
+ const mapperNode: Record = mapper(node);
+ if (mapperNode[childProps]) {
+ mapperNode[childProps] = mapTree(mapperNode[childProps], mapper, options);
+ }
+ return mapperNode as V;
+ });
+}
+
+/**
+ * 对树形结构数据进行递归排序
+ * @param treeData - 树形数据数组
+ * @param sortFunction - 排序函数,用于定义排序规则
+ * @param options - 配置选项,包括子节点属性名
+ * @returns 排序后的树形数据
+ */
+function sortTree>(
+ treeData: T[],
+ sortFunction: (a: T, b: T) => number,
+ options?: TreeConfigOptions,
+): T[] {
+ const { childProps } = options || {
+ childProps: 'children',
+ };
+
+ return treeData.toSorted(sortFunction).map((item) => {
+ const children = item[childProps];
+ if (children && Array.isArray(children) && children.length > 0) {
+ return {
+ ...item,
+ [childProps]: sortTree(children, sortFunction, options),
+ };
+ }
+ return item;
+ });
+}
+
+export { filterTree, mapTree, sortTree, traverseTreeValues };
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/unique.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/unique.ts
new file mode 100644
index 0000000..e81f972
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/unique.ts
@@ -0,0 +1,15 @@
+/**
+ * 根据指定字段对对象数组进行去重
+ * @param arr 要去重的对象数组
+ * @param key 去重依据的字段名
+ * @returns 去重后的对象数组
+ */
+function uniqueByField(arr: T[], key: keyof T): T[] {
+ const seen = new Map();
+ return arr.filter((item) => {
+ const value = item[key];
+ return seen.has(value) ? false : (seen.set(value, item), true);
+ });
+}
+
+export { uniqueByField };
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/update-css-variables.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/update-css-variables.ts
new file mode 100644
index 0000000..296e2e5
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/update-css-variables.ts
@@ -0,0 +1,40 @@
+/**
+ * 更新 CSS 变量的函数
+ * @param variables 要更新的 CSS 变量与其新值的映射
+ * @param id 内联样式表的 id,便于复用与覆盖
+ * @param selector CSS 变量挂载的选择器,默认 `:root`。
+ * 对于像 TDesign 这种将变量定义在 `:root[theme-mode='dark']` 等更高优先级选择器下的组件库,
+ * 需要传入相同(或更高)优先级的选择器才能正确覆盖。
+ */
+function updateCSSVariables(
+ variables: { [key: string]: string },
+ id = '__vben-styles__',
+ selector = ':root',
+): void {
+ // 获取或创建内联样式表元素
+ const styleElement =
+ document.querySelector(`#${id}`) || document.createElement('style');
+
+ styleElement.id = id;
+
+ // 构建要更新的 CSS 变量的样式文本
+ let cssText = `${selector} {`;
+ for (const key in variables) {
+ if (Object.prototype.hasOwnProperty.call(variables, key)) {
+ cssText += `${key}: ${variables[key]};`;
+ }
+ }
+ cssText += '}';
+
+ // 将样式文本赋值给内联样式表
+ styleElement.textContent = cssText;
+
+ // 将内联样式表添加到文档头部
+ if (!document.querySelector(`#${id}`)) {
+ setTimeout(() => {
+ document.head.append(styleElement);
+ });
+ }
+}
+
+export { updateCSSVariables };
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/util.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/util.ts
new file mode 100644
index 0000000..885eeaa
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/util.ts
@@ -0,0 +1,44 @@
+export function bindMethods(instance: T): void {
+ const prototype = Object.getPrototypeOf(instance);
+ const propertyNames = Object.getOwnPropertyNames(prototype);
+
+ propertyNames.forEach((propertyName) => {
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, propertyName);
+ const propertyValue = instance[propertyName as keyof T];
+
+ if (
+ typeof propertyValue === 'function' &&
+ propertyName !== 'constructor' &&
+ descriptor &&
+ !descriptor.get &&
+ !descriptor.set
+ ) {
+ instance[propertyName as keyof T] = propertyValue.bind(instance);
+ }
+ });
+}
+
+/**
+ * 获取嵌套对象的字段值
+ * @param obj - 要查找的对象
+ * @param path - 用于查找字段的路径,使用小数点分隔
+ * @returns 字段值,或者未找到时返回 undefined
+ */
+export function getNestedValue(obj: T, path: string): any {
+ if (typeof path !== 'string' || path.length === 0) {
+ throw new Error('Path must be a non-empty string');
+ }
+ // 把路径字符串按 "." 分割成数组
+ const keys = path.split('.') as (number | string)[];
+
+ let current: any = obj;
+
+ for (const key of keys) {
+ if (current === null || current === undefined) {
+ return undefined;
+ }
+ current = current[key as keyof typeof current];
+ }
+
+ return current;
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/window.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/window.ts
new file mode 100644
index 0000000..2d8697d
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/src/utils/window.ts
@@ -0,0 +1,37 @@
+interface OpenWindowOptions {
+ noopener?: boolean;
+ noreferrer?: boolean;
+ target?: '_blank' | '_parent' | '_self' | '_top' | string;
+}
+
+/**
+ * 新窗口打开URL。
+ *
+ * @param url - 需要打开的网址。
+ * @param options - 打开窗口的选项。
+ */
+function openWindow(url: string, options: OpenWindowOptions = {}): void {
+ // 解构并设置默认值
+ const { noopener = true, noreferrer = true, target = '_blank' } = options;
+
+ // 基于选项创建特性字符串
+ const features = [noopener && 'noopener=yes', noreferrer && 'noreferrer=yes']
+ .filter(Boolean)
+ .join(',');
+
+ // 打开窗口
+ window.open(url, target, features);
+}
+
+/**
+ * 在新窗口中打开路由。
+ * @param path
+ */
+function openRouteInNewWindow(path: string) {
+ const { hash, origin } = location;
+ const fullPath = path.startsWith('/') ? path : `/${path}`;
+ const url = `${origin}${hash && !fullPath.startsWith('/#') ? '/#' : ''}${fullPath}`;
+ openWindow(url, { target: '_blank' });
+}
+
+export { openRouteInNewWindow, openWindow };
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/tsconfig.json b/deploy/fba/fba-ui-src/packages/@core/base/shared/tsconfig.json
new file mode 100644
index 0000000..f6860a3
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "@vben/tsconfig/library.json",
+ "include": ["src"],
+ "exclude": ["node_modules"]
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/shared/tsdown.config.ts b/deploy/fba/fba-ui-src/packages/@core/base/shared/tsdown.config.ts
new file mode 100644
index 0000000..62be779
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/shared/tsdown.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from 'tsdown';
+
+export default defineConfig({
+ clean: true,
+ dts: true,
+ entry: {
+ 'cache/index': 'src/cache/index.ts',
+ 'color/index': 'src/color/index.ts',
+ 'constants/index': 'src/constants/index.ts',
+ 'global-state': 'src/global-state.ts',
+ store: 'src/store.ts',
+ 'utils/index': 'src/utils/index.ts',
+ },
+ format: ['esm'],
+ outExtensions: () => ({
+ dts: '.d.ts',
+ }),
+});
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/typings/package.json b/deploy/fba/fba-ui-src/packages/@core/base/typings/package.json
new file mode 100644
index 0000000..b90f3df
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/typings/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@vben-core/typings",
+ "version": "5.7.0",
+ "homepage": "https://github.com/vbenjs/vue-vben-admin",
+ "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/vbenjs/vue-vben-admin.git",
+ "directory": "packages/@vben-core/base/typings"
+ },
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "build": "pnpm exec tsdown"
+ },
+ "files": [
+ "dist",
+ "vue-router.d.ts"
+ ],
+ "main": "./dist/index.mjs",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "development": "./src/index.ts",
+ "production": "./src/index.ts",
+ "default": "./dist/index.mjs"
+ },
+ "./vue-router": {
+ "types": "./vue-router.d.ts"
+ }
+ },
+ "publishConfig": {
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.mjs"
+ }
+ }
+ },
+ "dependencies": {
+ "vue": "catalog:",
+ "vue-router": "catalog:"
+ }
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/typings/src/app.d.ts b/deploy/fba/fba-ui-src/packages/@core/base/typings/src/app.d.ts
new file mode 100644
index 0000000..dc6081b
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/typings/src/app.d.ts
@@ -0,0 +1,126 @@
+type LayoutType =
+ | 'full-content'
+ | 'header-mixed-nav'
+ | 'header-nav'
+ | 'header-sidebar-nav'
+ | 'mixed-nav'
+ | 'sidebar-mixed-nav'
+ | 'sidebar-nav';
+
+type ThemeModeType = 'auto' | 'dark' | 'light';
+
+/**
+ * 按钮位置
+ * user-dropdown 用户的下拉弹出框中
+ * fixed 固定在右侧
+ * header 顶栏
+ * auto 自动
+ */
+type PreferencesButtonPositionType =
+ | 'auto'
+ | 'fixed'
+ | 'header'
+ | 'user-dropdown';
+
+type BuiltinThemeType =
+ | 'custom'
+ | 'deep-blue'
+ | 'deep-green'
+ | 'default'
+ | 'gray'
+ | 'green'
+ | 'neutral'
+ | 'orange'
+ | 'pink'
+ | 'red'
+ | 'rose'
+ | 'sky-blue'
+ | 'slate'
+ | 'stone'
+ | 'violet'
+ | 'yellow'
+ | 'zinc'
+ | (Record & string);
+
+type ContentCompactType = 'compact' | 'wide';
+
+type LayoutHeaderModeType = 'auto' | 'auto-scroll' | 'fixed' | 'static';
+type LayoutHeaderMenuAlignType = 'center' | 'end' | 'start';
+
+/**
+ * 登录过期模式
+ * modal 弹窗模式
+ * page 页面模式
+ */
+type LoginExpiredModeType = 'modal' | 'page';
+
+/**
+ * 面包屑样式
+ * background 背景
+ * normal 默认
+ */
+type BreadcrumbStyleType = 'background' | 'normal';
+
+/**
+ * 权限模式
+ * backend 后端权限模式
+ * frontend 前端权限模式
+ * mixed 混合权限模式
+ */
+type AccessModeType = 'backend' | 'frontend' | 'mixed';
+
+/**
+ * 导航风格
+ * plain 朴素
+ * rounded 圆润
+ */
+type NavigationStyleType = 'plain' | 'rounded';
+
+/**
+ * 标签栏风格
+ * brisk 轻快
+ * card 卡片
+ * chrome 谷歌
+ * plain 朴素
+ */
+type TabsStyleType = 'brisk' | 'card' | 'chrome' | 'plain';
+
+/**
+ * 页面切换动画
+ */
+type PageTransitionType = 'fade' | 'fade-down' | 'fade-slide' | 'fade-up';
+
+/**
+ * 页面切换动画
+ * panel-center 居中布局
+ * panel-left 居左布局
+ * panel-right 居右布局
+ */
+type AuthPageLayoutType = 'panel-center' | 'panel-left' | 'panel-right';
+
+/**
+ * 时区选项
+ */
+interface TimezoneOption {
+ label: string;
+ offset: number;
+ timezone: string;
+}
+
+export type {
+ AccessModeType,
+ AuthPageLayoutType,
+ BreadcrumbStyleType,
+ BuiltinThemeType,
+ ContentCompactType,
+ LayoutHeaderMenuAlignType,
+ LayoutHeaderModeType,
+ LayoutType,
+ LoginExpiredModeType,
+ NavigationStyleType,
+ PageTransitionType,
+ PreferencesButtonPositionType,
+ TabsStyleType,
+ ThemeModeType,
+ TimezoneOption,
+};
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/typings/src/basic.d.ts b/deploy/fba/fba-ui-src/packages/@core/base/typings/src/basic.d.ts
new file mode 100644
index 0000000..35ed709
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/typings/src/basic.d.ts
@@ -0,0 +1,41 @@
+interface BasicOption {
+ label: string;
+ value: string;
+}
+
+type SelectOption = BasicOption;
+
+type TabOption = BasicOption;
+
+interface BasicUserInfo {
+ /**
+ * 头像
+ */
+ avatar: string;
+ /**
+ * 用户昵称
+ */
+ realName: string;
+ /**
+ * 用户角色
+ */
+ roles?: string[];
+ /**
+ * 用户id
+ */
+ userId: string;
+ /**
+ * 用户名
+ */
+ username: string;
+}
+
+type ClassType =
+ | Array
+ | boolean
+ | null
+ | object
+ | string
+ | undefined;
+
+export type { BasicOption, BasicUserInfo, ClassType, SelectOption, TabOption };
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/typings/src/helper.d.ts b/deploy/fba/fba-ui-src/packages/@core/base/typings/src/helper.d.ts
new file mode 100644
index 0000000..737b615
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/typings/src/helper.d.ts
@@ -0,0 +1,150 @@
+import type { ComputedRef, MaybeRef } from 'vue';
+
+/**
+ * 类型级递归中增加深度计数
+ */
+type Increment = [...A, unknown];
+/**
+ * 深层递归所有属性为可选
+ */
+type DeepPartial<
+ T,
+ D extends number = 10,
+ C extends unknown[] = [],
+> = C['length'] extends D
+ ? T
+ : T extends object
+ ? {
+ [P in keyof T]?: DeepPartial>;
+ }
+ : T;
+
+/**
+ * 深层递归所有属性为只读
+ */
+type DeepReadonly<
+ T,
+ D extends number = 10,
+ C extends unknown[] = [],
+> = C['length'] extends D
+ ? T
+ : T extends object
+ ? {
+ readonly [P in keyof T]: DeepReadonly>;
+ }
+ : T;
+
+/**
+ * 任意类型的异步函数
+ */
+
+type AnyPromiseFunction = (
+ ...arg: T
+) => PromiseLike;
+
+/**
+ * 任意类型的普通函数
+ */
+type AnyNormalFunction = (...arg: T) => R;
+
+/**
+ * 任意类型的函数
+ */
+type AnyFunction =
+ | AnyNormalFunction
+ | AnyPromiseFunction;
+
+/**
+ * T | null 包装
+ */
+type Nullable = null | T;
+
+/**
+ * T | Not null 包装
+ */
+type NonNullable = T extends null | undefined ? never : T;
+
+/**
+ * 字符串类型对象
+ */
+type Recordable = Record;
+
+/**
+ * 字符串类型对象(只读)
+ */
+interface ReadonlyRecordable {
+ readonly [key: string]: T;
+}
+
+/**
+ * setTimeout 返回值类型
+ */
+type TimeoutHandle = ReturnType;
+
+/**
+ * setInterval 返回值类型
+ */
+type IntervalHandle = ReturnType;
+
+/**
+ * 也许它是一个计算的 ref,或者一个 getter 函数
+ *
+ */
+type MaybeReadonlyRef = (() => T) | ComputedRef;
+
+/**
+ * 也许它是一个 ref,或者一个普通值,或者一个 getter 函数
+ *
+ */
+type MaybeComputedRef = MaybeReadonlyRef | MaybeRef;
+
+type Merge = {
+ [K in keyof O | keyof T]: K extends keyof T
+ ? T[K]
+ : K extends keyof O
+ ? O[K]
+ : never;
+};
+
+/**
+ * T = [
+ * { name: string; age: number; },
+ * { sex: 'male' | 'female'; age: string }
+ * ]
+ * =>
+ * MergeAll = {
+ * name: string;
+ * sex: 'male' | 'female';
+ * age: string
+ * }
+ */
+type MergeAll<
+ T extends object[],
+ R extends object = Record,
+> = T extends [infer F extends object, ...infer Rest extends object[]]
+ ? MergeAll>
+ : R;
+
+type EmitType = (name: Name, ...args: any[]) => void;
+
+type MaybePromise = Promise | T;
+
+export type {
+ AnyFunction,
+ AnyNormalFunction,
+ AnyPromiseFunction,
+ DeepPartial,
+ DeepReadonly,
+ EmitType,
+ IntervalHandle,
+ MaybeComputedRef,
+ MaybePromise,
+ MaybeReadonlyRef,
+ Merge,
+ MergeAll,
+ NonNullable,
+ Nullable,
+ ReadonlyRecordable,
+ Recordable,
+ TimeoutHandle,
+};
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/typings/src/index.ts b/deploy/fba/fba-ui-src/packages/@core/base/typings/src/index.ts
new file mode 100644
index 0000000..33c3566
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/typings/src/index.ts
@@ -0,0 +1,6 @@
+export type * from './app';
+export type * from './basic';
+export type * from './helper';
+export type * from './menu-record';
+export type * from './tabs';
+export type * from './vue-router';
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/typings/src/menu-record.ts b/deploy/fba/fba-ui-src/packages/@core/base/typings/src/menu-record.ts
new file mode 100644
index 0000000..8c45099
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/typings/src/menu-record.ts
@@ -0,0 +1,82 @@
+import type { Component } from 'vue';
+import type { RouteRecordRaw } from 'vue-router';
+
+import type { Recordable } from './helper';
+
+/**
+ * 扩展路由原始对象
+ */
+type ExRouteRecordRaw = RouteRecordRaw & {
+ parent?: string;
+ parents?: string[];
+ path?: any;
+};
+
+interface MenuRecordBadgeRaw {
+ /**
+ * 徽标
+ */
+ badge?: string;
+ /**
+ * 徽标类型
+ */
+ badgeType?: 'dot' | 'normal';
+ /**
+ * 徽标颜色
+ */
+ badgeVariants?: 'destructive' | 'primary' | string;
+}
+
+/**
+ * 菜单原始对象
+ */
+interface MenuRecordRaw extends MenuRecordBadgeRaw {
+ /**
+ * 激活时的图标名
+ */
+ activeIcon?: string;
+ /**
+ * 子菜单
+ */
+ children?: MenuRecordRaw[];
+ /**
+ * 是否禁用菜单
+ * @default false
+ */
+ disabled?: boolean;
+ /**
+ * 图标名
+ */
+ icon?: Component | string;
+ /**
+ * 菜单名
+ */
+ name: string;
+ /**
+ * 排序号
+ */
+ order?: number;
+ /**
+ * 父级路径
+ */
+ parent?: string;
+ /**
+ * 所有父级路径
+ */
+ parents?: string[];
+ /**
+ * 菜单路径,唯一,可当作key
+ */
+ path: string;
+ /**
+ * 菜单参数
+ */
+ query?: Recordable;
+ /**
+ * 是否显示菜单
+ * @default true
+ */
+ show?: boolean;
+}
+
+export type { ExRouteRecordRaw, MenuRecordBadgeRaw, MenuRecordRaw };
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/typings/src/tabs.ts b/deploy/fba/fba-ui-src/packages/@core/base/typings/src/tabs.ts
new file mode 100644
index 0000000..58f7d26
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/typings/src/tabs.ts
@@ -0,0 +1,8 @@
+import type { RouteLocationNormalized } from 'vue-router';
+
+export interface TabDefinition extends RouteLocationNormalized {
+ /**
+ * 标签页的key
+ */
+ key?: string;
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/typings/src/vue-router.d.ts b/deploy/fba/fba-ui-src/packages/@core/base/typings/src/vue-router.d.ts
new file mode 100644
index 0000000..d7a80fe
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/typings/src/vue-router.d.ts
@@ -0,0 +1,157 @@
+import type { Component } from 'vue';
+import type { Router, RouteRecordRaw } from 'vue-router';
+
+interface RouteMeta {
+ /**
+ * 激活图标(菜单/tab)
+ */
+ activeIcon?: string;
+ /**
+ * 当前激活的菜单,有时候不想激活现有菜单,需要激活父级菜单时使用
+ */
+ activePath?: string;
+ /**
+ * 是否固定标签页
+ * @default false
+ */
+ affixTab?: boolean;
+ /**
+ * 固定标签页的顺序
+ * @default 0
+ */
+ affixTabOrder?: number;
+ /**
+ * 需要特定的角色标识才可以访问
+ * @default []
+ */
+ authority?: string[];
+ /**
+ * 徽标
+ */
+ badge?: string;
+ /**
+ * 徽标类型
+ */
+ badgeType?: 'dot' | 'normal';
+ /**
+ * 徽标颜色
+ */
+ badgeVariants?:
+ | 'default'
+ | 'destructive'
+ | 'primary'
+ | 'success'
+ | 'warning'
+ | string;
+ /**
+ * 路由对应dom是否缓存起来
+ */
+ domCached?: boolean;
+ /**
+ * 路由的完整路径作为key(默认true)
+ */
+ fullPathKey?: boolean;
+ /**
+ * 当前路由的子级在菜单中不展现
+ * @default false
+ */
+ hideChildrenInMenu?: boolean;
+ /**
+ * 当前路由在面包屑中不展现
+ * @default false
+ */
+ hideInBreadcrumb?: boolean;
+ /**
+ * 当前路由在菜单中不展现
+ * @default false
+ */
+ hideInMenu?: boolean;
+ /**
+ * 当前路由在标签页不展现
+ * @default false
+ */
+ hideInTab?: boolean;
+ /**
+ * 图标(菜单/tab)
+ */
+ icon?: Component | string;
+ /**
+ * iframe 地址
+ */
+ iframeSrc?: string;
+ /**
+ * 忽略权限,直接可以访问
+ * @default false
+ */
+ ignoreAccess?: boolean;
+ /**
+ * 开启KeepAlive缓存
+ */
+ keepAlive?: boolean;
+ /**
+ * 外链-跳转路径
+ */
+ link?: string;
+ /**
+ * 路由是否已经加载过
+ */
+ loaded?: boolean;
+ /**
+ * 标签页最大打开数量
+ * @default -1
+ */
+ maxNumOfOpenTab?: number;
+ /**
+ * 菜单可以看到,但是访问会被重定向到403
+ */
+ menuVisibleWithForbidden?: boolean;
+ /**
+ * 不使用基础布局(仅在顶级生效)
+ */
+ noBasicLayout?: boolean;
+ /**
+ * 在新窗口打开
+ */
+ openInNewWindow?: boolean;
+ /**
+ * 用于路由->菜单排序
+ */
+ order?: number;
+ /**
+ * 菜单所携带的参数
+ */
+ query?: Recordable;
+ /**
+ * 标题名称
+ */
+ title: string;
+}
+
+// 定义递归类型以将 RouteRecordRaw 的 component 属性更改为 string
+type RouteRecordStringComponent = Omit<
+ RouteRecordRaw,
+ 'children' | 'component'
+> & {
+ children?: RouteRecordStringComponent[];
+ component: T;
+};
+
+type ComponentRecordType = Record Promise>;
+
+interface GenerateMenuAndRoutesOptions {
+ fetchMenuListAsync?: () => Promise;
+ forbiddenComponent?: RouteRecordRaw['component'];
+ layoutMap?: ComponentRecordType;
+ pageMap?: ComponentRecordType;
+ roles?: string[];
+ router: Router;
+ routes: RouteRecordRaw[];
+}
+
+export type {
+ ComponentRecordType,
+ GenerateMenuAndRoutesOptions,
+ RouteMeta,
+ RouteRecordRaw,
+ RouteRecordStringComponent,
+};
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/typings/tsconfig.json b/deploy/fba/fba-ui-src/packages/@core/base/typings/tsconfig.json
new file mode 100644
index 0000000..f6860a3
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/typings/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "@vben/tsconfig/library.json",
+ "include": ["src"],
+ "exclude": ["node_modules"]
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/typings/tsdown.config.ts b/deploy/fba/fba-ui-src/packages/@core/base/typings/tsdown.config.ts
new file mode 100644
index 0000000..c4f51c5
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/typings/tsdown.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'tsdown';
+
+export default defineConfig({
+ clean: true,
+ dts: true,
+ entry: ['src/index.ts'],
+ format: ['esm'],
+ outExtensions: () => ({
+ dts: '.d.ts',
+ }),
+});
diff --git a/deploy/fba/fba-ui-src/packages/@core/base/typings/vue-router.d.ts b/deploy/fba/fba-ui-src/packages/@core/base/typings/vue-router.d.ts
new file mode 100644
index 0000000..fc7baa3
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/base/typings/vue-router.d.ts
@@ -0,0 +1,8 @@
+import type { RouteMeta as IRouteMeta } from './dist/index.d.mts';
+
+import 'vue-router';
+
+declare module 'vue-router' {
+ // oxlint-disable-next-line typescript/no-empty-object-type
+ interface RouteMeta extends IRouteMeta {}
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/composables/package.json b/deploy/fba/fba-ui-src/packages/@core/composables/package.json
new file mode 100644
index 0000000..de58e59
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/composables/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@vben-core/composables",
+ "version": "5.7.0",
+ "homepage": "https://github.com/vbenjs/vue-vben-admin",
+ "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/vbenjs/vue-vben-admin.git",
+ "directory": "packages/@core/composables"
+ },
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "build": "pnpm exec tsdown"
+ },
+ "files": [
+ "dist"
+ ],
+ "sideEffects": false,
+ "main": "./dist/index.mjs",
+ "module": "./dist/index.mjs",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "development": "./src/index.ts",
+ "production": "./src/index.ts",
+ "default": "./dist/index.mjs"
+ }
+ },
+ "publishConfig": {
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.mjs"
+ }
+ }
+ },
+ "dependencies": {
+ "@vben-core/shared": "workspace:*",
+ "@vueuse/core": "catalog:",
+ "reka-ui": "catalog:",
+ "sortablejs": "catalog:",
+ "vue": "catalog:"
+ },
+ "devDependencies": {
+ "@types/sortablejs": "catalog:"
+ }
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/composables/src/__tests__/use-sortable.test.ts b/deploy/fba/fba-ui-src/packages/@core/composables/src/__tests__/use-sortable.test.ts
new file mode 100644
index 0000000..3524143
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/composables/src/__tests__/use-sortable.test.ts
@@ -0,0 +1,48 @@
+import type { SortableOptions } from 'sortablejs';
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useSortable } from '../use-sortable';
+
+describe('useSortable', () => {
+ beforeEach(() => {
+ vi.mock('sortablejs/modular/sortable.complete.esm.js', () => ({
+ default: {
+ create: vi.fn(),
+ },
+ }));
+ });
+ it('should call Sortable.create with the correct options', async () => {
+ // Create a mock element
+ const mockElement = document.createElement('div') as HTMLDivElement;
+
+ // Define custom options
+ const customOptions: SortableOptions = {
+ group: 'test-group',
+ sort: false,
+ };
+
+ // Use the useSortable function
+ const { initializeSortable } = useSortable(mockElement, customOptions);
+
+ // Initialize sortable
+ await initializeSortable();
+
+ // Import sortablejs to access the mocked create function
+ const Sortable =
+ // @ts-expect-error - This is a dynamic import
+ await import('sortablejs/modular/sortable.complete.esm.js');
+
+ // Verify that Sortable.create was called with the correct parameters
+ expect(Sortable.default.create).toHaveBeenCalledTimes(1);
+ expect(Sortable.default.create).toHaveBeenCalledWith(
+ mockElement,
+ expect.objectContaining({
+ animation: 300,
+ delay: 400,
+ delayOnTouchOnly: true,
+ ...customOptions,
+ }),
+ );
+ });
+});
diff --git a/deploy/fba/fba-ui-src/packages/@core/composables/src/index.ts b/deploy/fba/fba-ui-src/packages/@core/composables/src/index.ts
new file mode 100644
index 0000000..2dbd4b8
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/composables/src/index.ts
@@ -0,0 +1,13 @@
+export * from './use-is-mobile';
+export * from './use-layout-style';
+export * from './use-namespace';
+export * from './use-priority-value';
+export * from './use-scroll-lock';
+export * from './use-simple-locale';
+export * from './use-sortable';
+export {
+ useEmitAsProps,
+ useForwardExpose,
+ useForwardProps,
+ useForwardPropsEmits,
+} from 'reka-ui';
diff --git a/deploy/fba/fba-ui-src/packages/@core/composables/src/use-is-mobile.ts b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-is-mobile.ts
new file mode 100644
index 0000000..e35909f
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-is-mobile.ts
@@ -0,0 +1,7 @@
+import { breakpointsTailwind, useBreakpoints } from '@vueuse/core';
+
+export function useIsMobile() {
+ const breakpoints = useBreakpoints(breakpointsTailwind);
+ const isMobile = breakpoints.smaller('md');
+ return { isMobile };
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/composables/src/use-layout-style.ts b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-layout-style.ts
new file mode 100644
index 0000000..395e9e5
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-layout-style.ts
@@ -0,0 +1,87 @@
+import type { CSSProperties } from 'vue';
+
+import type { VisibleDomRect } from '@vben-core/shared/utils';
+
+import { computed, onMounted, onUnmounted, ref } from 'vue';
+
+import {
+ CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT,
+ CSS_VARIABLE_LAYOUT_CONTENT_WIDTH,
+ CSS_VARIABLE_LAYOUT_FOOTER_HEIGHT,
+ CSS_VARIABLE_LAYOUT_HEADER_HEIGHT,
+} from '@vben-core/shared/constants';
+import { getElementVisibleRect } from '@vben-core/shared/utils';
+
+import { useCssVar, useDebounceFn } from '@vueuse/core';
+
+/**
+ * @zh_CN content style
+ */
+export function useLayoutContentStyle() {
+ let resizeObserver: null | ResizeObserver = null;
+ const contentElement = ref(null);
+ const visibleDomRect = ref(null);
+ const contentHeight = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT);
+ const contentWidth = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_WIDTH);
+
+ const overlayStyle = computed((): CSSProperties => {
+ const { height, left, top, width } = visibleDomRect.value ?? {};
+ return {
+ height: `${height}px`,
+ left: `${left}px`,
+ position: 'fixed',
+ top: `${top}px`,
+ width: `${width}px`,
+ zIndex: 150,
+ };
+ });
+
+ const debouncedCalcHeight = useDebounceFn(
+ (_entries: ResizeObserverEntry[]) => {
+ visibleDomRect.value = getElementVisibleRect(contentElement.value);
+ contentHeight.value = `${visibleDomRect.value.height}px`;
+ contentWidth.value = `${visibleDomRect.value.width}px`;
+ },
+ 16,
+ );
+
+ onMounted(() => {
+ if (contentElement.value && !resizeObserver) {
+ resizeObserver = new ResizeObserver(debouncedCalcHeight);
+ resizeObserver.observe(contentElement.value);
+ }
+ });
+
+ onUnmounted(() => {
+ resizeObserver?.disconnect();
+ resizeObserver = null;
+ });
+
+ return { contentElement, overlayStyle, visibleDomRect };
+}
+
+export function useLayoutHeaderStyle() {
+ const headerHeight = useCssVar(CSS_VARIABLE_LAYOUT_HEADER_HEIGHT);
+
+ return {
+ getLayoutHeaderHeight: () => {
+ return Number.parseInt(`${headerHeight.value}`, 10);
+ },
+ setLayoutHeaderHeight: (height: number) => {
+ headerHeight.value = `${height}px`;
+ },
+ };
+}
+
+export function useLayoutFooterStyle() {
+ const footerHeight = useCssVar(CSS_VARIABLE_LAYOUT_FOOTER_HEIGHT);
+
+ return {
+ getLayoutFooterHeight: () => {
+ return Number.parseInt(`${footerHeight.value}`, 10);
+ },
+ setLayoutFooterHeight: (height: number) => {
+ footerHeight.value = `${height}px`;
+ },
+ };
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/composables/src/use-namespace.ts b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-namespace.ts
new file mode 100644
index 0000000..eb06a70
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-namespace.ts
@@ -0,0 +1,106 @@
+import { DEFAULT_NAMESPACE } from '@vben-core/shared/constants';
+
+/**
+ * @see copy https://github.com/element-plus/element-plus/blob/dev/packages/hooks/use-namespace/index.ts
+ */
+
+const statePrefix = 'is-';
+
+const _bem = (
+ namespace: string,
+ block: string,
+ blockSuffix: string,
+ element: string,
+ modifier: string,
+) => {
+ let cls = `${namespace}-${block}`;
+ if (blockSuffix) {
+ cls += `-${blockSuffix}`;
+ }
+ if (element) {
+ cls += `__${element}`;
+ }
+ if (modifier) {
+ cls += `--${modifier}`;
+ }
+ return cls;
+};
+
+const is: {
+ (name: string): string;
+ // oxlint-disable-next-line typescript/unified-signatures
+ (name: string, state: boolean | undefined): string;
+} = (name: string, ...args: [] | [boolean | undefined]) => {
+ const state = args.length > 0 ? args[0] : true;
+ return name && state ? `${statePrefix}${name}` : '';
+};
+
+const useNamespace = (block: string) => {
+ const namespace = DEFAULT_NAMESPACE;
+ const b = (blockSuffix = '') => _bem(namespace, block, blockSuffix, '', '');
+ const e = (element?: string) =>
+ element ? _bem(namespace, block, '', element, '') : '';
+ const m = (modifier?: string) =>
+ modifier ? _bem(namespace, block, '', '', modifier) : '';
+ const be = (blockSuffix?: string, element?: string) =>
+ blockSuffix && element
+ ? _bem(namespace, block, blockSuffix, element, '')
+ : '';
+ const em = (element?: string, modifier?: string) =>
+ element && modifier ? _bem(namespace, block, '', element, modifier) : '';
+ const bm = (blockSuffix?: string, modifier?: string) =>
+ blockSuffix && modifier
+ ? _bem(namespace, block, blockSuffix, '', modifier)
+ : '';
+ const bem = (blockSuffix?: string, element?: string, modifier?: string) =>
+ blockSuffix && element && modifier
+ ? _bem(namespace, block, blockSuffix, element, modifier)
+ : '';
+
+ // for css var
+ // --el-xxx: value;
+ const cssVar = (object: Record) => {
+ const styles: Record = {};
+ for (const key in object) {
+ if (object[key]) {
+ styles[`--${namespace}-${key}`] = object[key];
+ }
+ }
+ return styles;
+ };
+ // with block
+ const cssVarBlock = (object: Record) => {
+ const styles: Record = {};
+ for (const key in object) {
+ if (object[key]) {
+ styles[`--${namespace}-${block}-${key}`] = object[key];
+ }
+ }
+ return styles;
+ };
+
+ const cssVarName = (name: string) => `--${namespace}-${name}`;
+ const cssVarBlockName = (name: string) => `--${namespace}-${block}-${name}`;
+
+ return {
+ b,
+ be,
+ bem,
+ bm,
+ // css
+ cssVar,
+ cssVarBlock,
+ cssVarBlockName,
+ cssVarName,
+ e,
+ em,
+ is,
+ m,
+ namespace,
+ };
+};
+
+type UseNamespaceReturn = ReturnType;
+
+export type { UseNamespaceReturn };
+export { useNamespace };
diff --git a/deploy/fba/fba-ui-src/packages/@core/composables/src/use-priority-value.ts b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-priority-value.ts
new file mode 100644
index 0000000..74b5b5b
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-priority-value.ts
@@ -0,0 +1,94 @@
+import type { ComputedRef, Ref } from 'vue';
+
+import { computed, getCurrentInstance, unref, useAttrs, useSlots } from 'vue';
+
+import {
+ getFirstNonNullOrUndefined,
+ kebabToCamelCase,
+} from '@vben-core/shared/utils';
+
+/**
+ * 依次从插槽、attrs、props、state 中获取值
+ * @param key
+ * @param props
+ * @param state
+ */
+export function usePriorityValue<
+ T extends Record,
+ S extends Record,
+ K extends keyof T = keyof T,
+>(key: K, props: T, state: Readonly[>> | undefined) {
+ const instance = getCurrentInstance();
+ const slots = useSlots();
+ const attrs = useAttrs() as T;
+
+ const value = computed((): T[K] => {
+ // props不管有没有传,都会有默认值,会影响这里的顺序,
+ // 通过判断原始props是否有值来判断是否传入
+ const rawProps = (instance?.vnode?.props || {}) as T;
+
+ const standardRawProps = {} as T;
+
+ for (const [key, value] of Object.entries(rawProps)) {
+ standardRawProps[kebabToCamelCase(key) as K] = value;
+ }
+ const propsKey =
+ standardRawProps?.[key] === undefined ? undefined : props[key];
+
+ // slot可以关闭
+ return getFirstNonNullOrUndefined(
+ slots[key as string],
+ attrs[key],
+ propsKey,
+ state?.value?.[key as keyof S],
+ ) as T[K];
+ });
+
+ return value;
+}
+
+/**
+ * 批量获取state中的值(每个值都是ref)
+ * @param props
+ * @param state
+ */
+export function usePriorityValues<
+ T extends Record,
+ S extends Ref> = Readonly][, NoInfer>>,
+>(props: T, state: S | undefined) {
+ const result: { [K in keyof T]: ComputedRef } = {} as never;
+
+ (Object.keys(props) as (keyof T)[]).forEach((key) => {
+ result[key] = usePriorityValue(key as keyof typeof props, props, state);
+ });
+
+ return result;
+}
+
+/**
+ * 批量获取state中的值(集中在一个computed,用于透传)
+ * @param props
+ * @param state
+ */
+export function useForwardPriorityValues<
+ T extends Record,
+ S extends Ref> = Readonly][, NoInfer>>,
+>(props: T, state: S | undefined) {
+ const computedResult: { [K in keyof T]: ComputedRef } = {} as never;
+
+ (Object.keys(props) as (keyof T)[]).forEach((key) => {
+ computedResult[key] = usePriorityValue(
+ key as keyof typeof props,
+ props,
+ state,
+ );
+ });
+
+ return computed(() => {
+ const unwrapResult: Record = {};
+ Object.keys(props).forEach((key) => {
+ unwrapResult[key] = unref(computedResult[key]);
+ });
+ return unwrapResult as { [K in keyof T]: T[K] };
+ });
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/composables/src/use-scroll-lock.ts b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-scroll-lock.ts
new file mode 100644
index 0000000..d1c1497
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-scroll-lock.ts
@@ -0,0 +1,54 @@
+import { getScrollbarWidth, needsScrollbar } from '@vben-core/shared/utils';
+
+import {
+ useScrollLock as _useScrollLock,
+ tryOnBeforeUnmount,
+ tryOnMounted,
+} from '@vueuse/core';
+
+export const SCROLL_FIXED_CLASS = `_scroll__fixed_`;
+
+export function useScrollLock() {
+ const isLocked = _useScrollLock(document.body);
+ const scrollbarWidth = getScrollbarWidth();
+
+ tryOnMounted(() => {
+ if (!needsScrollbar()) {
+ return;
+ }
+ document.body.style.paddingRight = `${scrollbarWidth}px`;
+
+ const layoutFixedNodes = document.querySelectorAll(
+ `.${SCROLL_FIXED_CLASS}`,
+ );
+ const nodes = [...layoutFixedNodes];
+ if (nodes.length > 0) {
+ nodes.forEach((node) => {
+ node.dataset.transition = node.style.transition;
+ node.style.transition = 'none';
+ node.style.paddingRight = `${scrollbarWidth}px`;
+ });
+ }
+ isLocked.value = true;
+ });
+
+ tryOnBeforeUnmount(() => {
+ if (!needsScrollbar()) {
+ return;
+ }
+ isLocked.value = false;
+ const layoutFixedNodes = document.querySelectorAll(
+ `.${SCROLL_FIXED_CLASS}`,
+ );
+ const nodes = [...layoutFixedNodes];
+ if (nodes.length > 0) {
+ nodes.forEach((node) => {
+ node.style.paddingRight = '';
+ requestAnimationFrame(() => {
+ node.style.transition = node.dataset.transition || '';
+ });
+ });
+ }
+ document.body.style.paddingRight = '';
+ });
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/composables/src/use-simple-locale/README.md b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-simple-locale/README.md
new file mode 100644
index 0000000..c0a676d
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-simple-locale/README.md
@@ -0,0 +1,3 @@
+# Simple i18n
+
+Simple i18 implementation
diff --git a/deploy/fba/fba-ui-src/packages/@core/composables/src/use-simple-locale/index.ts b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-simple-locale/index.ts
new file mode 100644
index 0000000..67b8173
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-simple-locale/index.ts
@@ -0,0 +1,27 @@
+import type { Locale } from './messages';
+
+import { computed, ref } from 'vue';
+
+import { createSharedComposable } from '@vueuse/core';
+
+import { getMessages } from './messages';
+
+export const useSimpleLocale = createSharedComposable(() => {
+ const currentLocale = ref('zh-CN');
+
+ const setSimpleLocale = (locale: Locale) => {
+ currentLocale.value = locale;
+ };
+
+ const $t = computed(() => {
+ const localeMessages = getMessages(currentLocale.value);
+ return (key: string) => {
+ return localeMessages[key] || key;
+ };
+ });
+ return {
+ $t,
+ currentLocale,
+ setSimpleLocale,
+ };
+});
diff --git a/deploy/fba/fba-ui-src/packages/@core/composables/src/use-simple-locale/messages.ts b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-simple-locale/messages.ts
new file mode 100644
index 0000000..671ae33
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-simple-locale/messages.ts
@@ -0,0 +1,26 @@
+export type Locale = 'en-US' | 'zh-CN';
+
+export const messages: Record> = {
+ 'en-US': {
+ cancel: 'Cancel',
+ collapse: 'Collapse',
+ confirm: 'Confirm',
+ expand: 'Expand',
+ prompt: 'Prompt',
+ reset: 'Reset',
+ submit: 'Submit',
+ confirmTitle: 'Please Confirm',
+ },
+ 'zh-CN': {
+ cancel: '取消',
+ collapse: '收起',
+ confirm: '确认',
+ expand: '展开',
+ prompt: '提示',
+ reset: '重置',
+ submit: '提交',
+ confirmTitle: '请确认',
+ },
+};
+
+export const getMessages = (locale: Locale) => messages[locale];
diff --git a/deploy/fba/fba-ui-src/packages/@core/composables/src/use-sortable.ts b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-sortable.ts
new file mode 100644
index 0000000..57f87a6
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/composables/src/use-sortable.ts
@@ -0,0 +1,29 @@
+import type { SortableOptions } from 'sortablejs';
+import type Sortable from 'sortablejs';
+
+function useSortable(
+ sortableContainer: T,
+ options: SortableOptions = {},
+) {
+ const initializeSortable = async () => {
+ const Sortable = await import(
+ // @ts-expect-error - This is a dynamic import
+ 'sortablejs/modular/sortable.complete.esm.js'
+ );
+ const sortable = Sortable?.default?.create?.(sortableContainer, {
+ animation: 300,
+ delay: 400,
+ delayOnTouchOnly: true,
+ ...options,
+ });
+ return sortable as Sortable;
+ };
+
+ return {
+ initializeSortable,
+ };
+}
+
+export { useSortable };
+
+export type { Sortable };
diff --git a/deploy/fba/fba-ui-src/packages/@core/composables/tsconfig.json b/deploy/fba/fba-ui-src/packages/@core/composables/tsconfig.json
new file mode 100644
index 0000000..f6860a3
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/composables/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "@vben/tsconfig/library.json",
+ "include": ["src"],
+ "exclude": ["node_modules"]
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/composables/tsdown.config.ts b/deploy/fba/fba-ui-src/packages/@core/composables/tsdown.config.ts
new file mode 100644
index 0000000..c4f51c5
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/composables/tsdown.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'tsdown';
+
+export default defineConfig({
+ clean: true,
+ dts: true,
+ entry: ['src/index.ts'],
+ format: ['esm'],
+ outExtensions: () => ({
+ dts: '.d.ts',
+ }),
+});
diff --git a/deploy/fba/fba-ui-src/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap b/deploy/fba/fba-ui-src/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap
new file mode 100644
index 0000000..2d0fca5
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap
@@ -0,0 +1,147 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`defaultPreferences immutability test > should not modify the config object 1`] = `
+{
+ "app": {
+ "accessMode": "frontend",
+ "authPageLayout": "panel-right",
+ "checkUpdatesInterval": 1,
+ "colorGrayMode": false,
+ "colorWeakMode": false,
+ "compact": false,
+ "contentCompact": "wide",
+ "contentCompactWidth": 1200,
+ "contentPadding": 0,
+ "contentPaddingBottom": 0,
+ "contentPaddingLeft": 0,
+ "contentPaddingRight": 0,
+ "contentPaddingTop": 0,
+ "defaultAvatar": "https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp",
+ "defaultHomePath": "/analytics",
+ "dynamicTitle": true,
+ "enableCheckUpdates": true,
+ "enableCopyPreferences": true,
+ "enablePreferences": true,
+ "enableRefreshToken": false,
+ "enableStickyPreferencesNavigationBar": true,
+ "isMobile": false,
+ "layout": "sidebar-nav",
+ "locale": "zh-CN",
+ "loginExpiredMode": "page",
+ "name": "Vben Admin",
+ "preferencesButtonPosition": "auto",
+ "timezone": "Asia/Shanghai",
+ "watermark": false,
+ "watermarkContent": "",
+ "zIndex": 200,
+ },
+ "breadcrumb": {
+ "enable": true,
+ "hideOnlyOne": false,
+ "showHome": false,
+ "showIcon": true,
+ "styleType": "normal",
+ },
+ "copyright": {
+ "companyName": "Vben",
+ "companySiteLink": "https://www.vben.pro",
+ "date": "2024",
+ "enable": true,
+ "icp": "",
+ "icpLink": "",
+ "settingShow": true,
+ },
+ "footer": {
+ "enable": false,
+ "fixed": false,
+ "height": 32,
+ },
+ "header": {
+ "enable": true,
+ "height": 50,
+ "hidden": false,
+ "menuAlign": "start",
+ "mode": "fixed",
+ },
+ "logo": {
+ "enable": true,
+ "fit": "contain",
+ "source": "https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp",
+ },
+ "navigation": {
+ "accordion": true,
+ "split": true,
+ "styleType": "rounded",
+ },
+ "shortcutKeys": {
+ "enable": true,
+ "globalEscape": false,
+ "globalLockScreen": true,
+ "globalLogout": true,
+ "globalPreferences": true,
+ "globalSearch": true,
+ },
+ "sidebar": {
+ "autoActivateChild": false,
+ "collapseWidth": 60,
+ "collapsed": false,
+ "collapsedButton": true,
+ "collapsedShowTitle": false,
+ "draggable": true,
+ "enable": true,
+ "expandOnHover": true,
+ "extraCollapse": false,
+ "extraCollapsedWidth": 60,
+ "fixedButton": true,
+ "hidden": false,
+ "mixedWidth": 80,
+ "width": 224,
+ },
+ "tabbar": {
+ "draggable": true,
+ "enable": true,
+ "height": 38,
+ "keepAlive": true,
+ "maxCount": 0,
+ "middleClickToClose": false,
+ "persist": true,
+ "showIcon": true,
+ "showMaximize": true,
+ "showMore": true,
+ "showRefresh": true,
+ "styleType": "chrome",
+ "visitHistory": true,
+ "wheelable": true,
+ },
+ "theme": {
+ "builtinType": "default",
+ "colorDestructive": "hsl(348 100% 61%)",
+ "colorPrimary": "hsl(212 100% 45%)",
+ "colorSuccess": "hsl(144 57% 58%)",
+ "colorWarning": "hsl(42 84% 61%)",
+ "fontSize": 16,
+ "mode": "dark",
+ "radius": "0.5",
+ "semiDarkHeader": false,
+ "semiDarkSidebar": false,
+ "semiDarkSidebarSub": false,
+ },
+ "transition": {
+ "enable": true,
+ "loading": true,
+ "name": "fade-slide",
+ "progress": true,
+ },
+ "widget": {
+ "fullscreen": true,
+ "globalSearch": true,
+ "languageToggle": true,
+ "lockScreen": true,
+ "notification": true,
+ "refresh": true,
+ "sidebarToggle": true,
+ "themeToggle": true,
+ "timezone": true,
+ },
+}
+`;
diff --git a/deploy/fba/fba-ui-src/packages/@core/preferences/__tests__/config.test.ts b/deploy/fba/fba-ui-src/packages/@core/preferences/__tests__/config.test.ts
new file mode 100644
index 0000000..f7c9bb3
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/preferences/__tests__/config.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from 'vitest';
+
+import { defaultPreferences } from '../src/config';
+
+describe('defaultPreferences immutability test', () => {
+ // 创建快照,确保默认配置对象不被修改
+ it('should not modify the config object', () => {
+ expect(defaultPreferences).toMatchSnapshot();
+ });
+});
diff --git a/deploy/fba/fba-ui-src/packages/@core/preferences/__tests__/preferences.test.ts b/deploy/fba/fba-ui-src/packages/@core/preferences/__tests__/preferences.test.ts
new file mode 100644
index 0000000..6afdb21
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/preferences/__tests__/preferences.test.ts
@@ -0,0 +1,546 @@
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { defaultPreferences } from '../src/config';
+import { isDarkTheme } from '../src/update-css-variables';
+
+describe('preferences', () => {
+ let PreferenceManager: typeof import('../src/preferences').PreferenceManager;
+ let preferenceManager: InstanceType<
+ typeof import('../src/preferences').PreferenceManager
+ >;
+
+ // 模拟 window.matchMedia 方法
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn().mockImplementation((query) => ({
+ addEventListener: vi.fn(),
+ addListener: vi.fn(), // Deprecated
+ dispatchEvent: vi.fn(),
+ matches: query === '(prefers-color-scheme: dark)',
+ media: query,
+ onchange: null,
+ removeEventListener: vi.fn(),
+ removeListener: vi.fn(), // Deprecated
+ })),
+ );
+
+ vi.stubGlobal('localStorage', {
+ clear: vi.fn(),
+ getItem: vi.fn(() => null),
+ key: vi.fn(() => null),
+ length: 0,
+ removeItem: vi.fn(),
+ setItem: vi.fn(),
+ });
+
+ vi.stubGlobal('sessionStorage', {
+ clear: vi.fn(),
+ getItem: vi.fn(() => null),
+ key: vi.fn(() => null),
+ length: 0,
+ removeItem: vi.fn(),
+ setItem: vi.fn(),
+ });
+
+ beforeAll(async () => {
+ ({ PreferenceManager } = await import('../src/preferences'));
+ });
+
+ beforeEach(() => {
+ vi.mocked(localStorage.getItem).mockImplementation(() => null);
+ vi.mocked(localStorage.removeItem).mockReset();
+ vi.mocked(localStorage.setItem).mockReset();
+ vi.mocked(sessionStorage.getItem).mockImplementation(() => null);
+ vi.mocked(sessionStorage.removeItem).mockReset();
+ vi.mocked(sessionStorage.setItem).mockReset();
+ preferenceManager = new PreferenceManager();
+ });
+
+ it('loads default preferences if no saved preferences found', () => {
+ const preferences = preferenceManager.getPreferences();
+ expect(preferences).toEqual(defaultPreferences);
+ });
+
+ it('initializes preferences with overrides', async () => {
+ const overrides: any = {
+ app: {
+ locale: 'en-US',
+ },
+ };
+ await preferenceManager.initPreferences({
+ namespace: 'testNamespace',
+ overrides,
+ });
+
+ // 等待防抖动操作完成
+ // await new Promise((resolve) => setTimeout(resolve, 300)); // 等待100毫秒
+
+ const expected = {
+ ...defaultPreferences,
+ app: {
+ ...defaultPreferences.app,
+ ...overrides.app,
+ },
+ };
+
+ expect(preferenceManager.getPreferences()).toEqual(expected);
+ });
+
+ it('updates theme mode correctly', () => {
+ preferenceManager.updatePreferences({
+ theme: {
+ mode: 'light',
+ },
+ });
+
+ expect(preferenceManager.getPreferences().theme.mode).toBe('light');
+ });
+
+ it('updates color modes correctly', () => {
+ preferenceManager.updatePreferences({
+ app: { colorGrayMode: true, colorWeakMode: true },
+ });
+
+ expect(preferenceManager.getPreferences().app.colorGrayMode).toBe(true);
+ expect(preferenceManager.getPreferences().app.colorWeakMode).toBe(true);
+ });
+
+ it('resets preferences to default', async () => {
+ // 先更新一些偏好设置
+ preferenceManager.updatePreferences({
+ theme: {
+ mode: 'light',
+ },
+ });
+
+ // 然后重置偏好设置
+ await preferenceManager.resetPreferences();
+
+ expect(preferenceManager.getPreferences()).toEqual(defaultPreferences);
+ });
+
+ it('updates isMobile correctly', () => {
+ // 模拟移动端状态
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn().mockImplementation((query) => ({
+ addEventListener: vi.fn(),
+ addListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ matches: query === '(max-width: 768px)',
+ media: query,
+ onchange: null,
+ removeEventListener: vi.fn(),
+ removeListener: vi.fn(),
+ })),
+ );
+
+ preferenceManager.updatePreferences({
+ app: { isMobile: true },
+ });
+
+ expect(preferenceManager.getPreferences().app.isMobile).toBe(true);
+ });
+
+ it('updates the locale preference correctly', () => {
+ preferenceManager.updatePreferences({
+ app: { locale: 'en-US' },
+ });
+
+ expect(preferenceManager.getPreferences().app.locale).toBe('en-US');
+ });
+
+ it('updates the sidebar width correctly', () => {
+ preferenceManager.updatePreferences({
+ sidebar: { width: 200 },
+ });
+
+ expect(preferenceManager.getPreferences().sidebar.width).toBe(200);
+ });
+ it('updates the sidebar collapse state correctly', () => {
+ preferenceManager.updatePreferences({
+ sidebar: { collapsed: true },
+ });
+
+ expect(preferenceManager.getPreferences().sidebar.collapsed).toBe(true);
+ });
+ it('updates the navigation style type correctly', () => {
+ preferenceManager.updatePreferences({
+ navigation: { styleType: 'flat' },
+ } as any);
+
+ expect(preferenceManager.getPreferences().navigation.styleType).toBe(
+ 'flat',
+ );
+ });
+
+ it('resets preferences to default correctly', async () => {
+ // 先更新一些偏好设置
+ preferenceManager.updatePreferences({
+ app: { locale: 'en-US' },
+ sidebar: { collapsed: true, width: 200 },
+ theme: {
+ mode: 'light',
+ },
+ });
+
+ // 然后重置偏好设置
+ await preferenceManager.resetPreferences();
+
+ expect(preferenceManager.getPreferences()).toEqual(defaultPreferences);
+ });
+
+ it('does not update undefined preferences', () => {
+ const originalPreferences = preferenceManager.getPreferences();
+
+ preferenceManager.updatePreferences({
+ app: { nonexistentField: 'value' },
+ } as any);
+
+ expect(preferenceManager.getPreferences()).toEqual(originalPreferences);
+ });
+
+ it('reverts to default when a preference field is deleted', () => {
+ preferenceManager.updatePreferences({
+ app: { locale: 'en-US' },
+ });
+
+ preferenceManager.updatePreferences({
+ app: { locale: undefined },
+ });
+
+ expect(preferenceManager.getPreferences().app.locale).toBe('en-US');
+ });
+
+ it('ignores updates with invalid preference value types', () => {
+ const originalPreferences = preferenceManager.getPreferences();
+
+ preferenceManager.updatePreferences({
+ app: { isMobile: 'true' as unknown as boolean }, // 错误类型
+ });
+
+ expect(preferenceManager.getPreferences()).toEqual(originalPreferences);
+ });
+
+ it('merges nested preference objects correctly', () => {
+ preferenceManager.updatePreferences({
+ app: { name: 'New App Name' },
+ });
+
+ const expected = {
+ ...defaultPreferences,
+ app: {
+ ...defaultPreferences.app,
+ name: 'New App Name',
+ },
+ };
+
+ expect(preferenceManager.getPreferences()).toEqual(expected);
+ });
+
+ it('applies updates immediately after initialization', async () => {
+ const overrides: any = {
+ app: {
+ locale: 'en-US',
+ },
+ };
+
+ await preferenceManager.initPreferences({
+ namespace: 'apply-updates',
+ overrides,
+ });
+
+ preferenceManager.updatePreferences({
+ theme: { mode: 'light' },
+ });
+
+ expect(preferenceManager.getPreferences().theme.mode).toBe('light');
+ });
+
+ it('initializes custom preferences extension with default values', async () => {
+ const extension = {
+ fields: [
+ {
+ component: 'switch',
+ defaultValue: true,
+ key: 'enableWorkbench',
+ label: '启用工作台',
+ },
+ {
+ component: 'select',
+ defaultValue: 'single',
+ key: 'tenantMode',
+ label: '租户模式',
+ options: [
+ { label: '单租户', value: 'single' },
+ { label: '多租户', value: 'multi' },
+ ],
+ },
+ ],
+ tabLabel: '扩展',
+ title: '业务偏好',
+ } as const;
+
+ await preferenceManager.initPreferences({
+ extension,
+ namespace: 'custom-defaults',
+ });
+
+ expect(preferenceManager.getPreferencesExtension()).toEqual(extension);
+ expect(preferenceManager.getCustomPreferences()).toEqual({
+ enableWorkbench: true,
+ tenantMode: 'single',
+ });
+ });
+
+ it('does not expose mutable custom preference baselines or extension schema', async () => {
+ const extension = {
+ fields: [
+ {
+ component: 'number',
+ componentProps: {
+ max: 10,
+ min: 2,
+ step: 2,
+ },
+ defaultValue: 4,
+ key: 'pageSize',
+ label: '分页大小',
+ },
+ ],
+ tabLabel: '扩展',
+ title: '业务偏好',
+ } as const;
+
+ await preferenceManager.initPreferences({
+ extension,
+ namespace: 'custom-readonly',
+ });
+
+ const initialCustomPreferences =
+ preferenceManager.getInitialCustomPreferences<{
+ pageSize: number;
+ }>() as { pageSize: number };
+ const preferencesExtension = preferenceManager.getPreferencesExtension<{
+ pageSize: number;
+ }>() as {
+ fields: Array<{ componentProps?: { max?: number }; label: string }>;
+ };
+ const [firstField] = preferencesExtension.fields;
+
+ initialCustomPreferences.pageSize = 8;
+ expect(firstField).toBeDefined();
+ expect(firstField?.componentProps).toBeDefined();
+
+ if (!firstField || !firstField.componentProps) {
+ return;
+ }
+
+ firstField.label = '已修改';
+ firstField.componentProps.max = 20;
+
+ expect(preferenceManager.getInitialCustomPreferences()).toEqual({
+ pageSize: 4,
+ });
+ expect(preferenceManager.getPreferencesExtension()).toEqual(extension);
+ });
+
+ it('updates and resets custom preferences correctly', async () => {
+ await preferenceManager.initPreferences({
+ extension: {
+ fields: [
+ {
+ component: 'number',
+ defaultValue: 20,
+ key: 'pageSize',
+ label: '分页大小',
+ },
+ {
+ component: 'input',
+ defaultValue: '日报',
+ key: 'reportTitle',
+ label: '报表标题',
+ },
+ ],
+ tabLabel: '扩展',
+ },
+ namespace: 'custom-reset',
+ });
+
+ preferenceManager.updateCustomPreferences({
+ pageSize: 50,
+ reportTitle: '月报',
+ });
+
+ expect(preferenceManager.getCustomPreferences()).toEqual({
+ pageSize: 50,
+ reportTitle: '月报',
+ });
+
+ await preferenceManager.resetPreferences();
+
+ expect(preferenceManager.getCustomPreferences()).toEqual({
+ pageSize: 20,
+ reportTitle: '日报',
+ });
+ });
+
+ it('ignores invalid custom preferences updates', async () => {
+ await preferenceManager.initPreferences({
+ extension: {
+ fields: [
+ {
+ component: 'switch',
+ defaultValue: true,
+ key: 'enableWorkbench',
+ label: '启用工作台',
+ },
+ {
+ component: 'select',
+ defaultValue: 'single',
+ key: 'tenantMode',
+ label: '租户模式',
+ options: [
+ { label: '单租户', value: 'single' },
+ { label: '多租户', value: 'multi' },
+ ],
+ },
+ ],
+ tabLabel: '扩展',
+ },
+ namespace: 'custom-invalid',
+ });
+
+ const originalCustomPreferences = preferenceManager.getCustomPreferences();
+
+ preferenceManager.updateCustomPreferences({
+ enableWorkbench: 'true' as unknown as boolean,
+ tenantMode: 'unknown',
+ unknownField: 'value',
+ } as any);
+
+ expect(preferenceManager.getCustomPreferences()).toEqual(
+ originalCustomPreferences,
+ );
+ });
+
+ it('enforces custom number field min max and step constraints', async () => {
+ await preferenceManager.initPreferences({
+ extension: {
+ fields: [
+ {
+ component: 'number',
+ componentProps: {
+ max: 10,
+ min: 2,
+ step: 2,
+ },
+ defaultValue: 4,
+ key: 'pageSize',
+ label: '分页大小',
+ },
+ ],
+ tabLabel: '扩展',
+ },
+ namespace: 'custom-number-constraints',
+ });
+
+ preferenceManager.updateCustomPreferences({
+ pageSize: 8,
+ });
+
+ expect(preferenceManager.getCustomPreferences()).toEqual({
+ pageSize: 8,
+ });
+
+ preferenceManager.updateCustomPreferences({
+ pageSize: 1,
+ });
+
+ expect(preferenceManager.getCustomPreferences()).toEqual({
+ pageSize: 8,
+ });
+
+ preferenceManager.updateCustomPreferences({
+ pageSize: 12,
+ });
+
+ expect(preferenceManager.getCustomPreferences()).toEqual({
+ pageSize: 8,
+ });
+
+ preferenceManager.updateCustomPreferences({
+ pageSize: 5,
+ });
+
+ expect(preferenceManager.getCustomPreferences()).toEqual({
+ pageSize: 8,
+ });
+ });
+
+ it('filters cached custom number values that violate field constraints', async () => {
+ vi.mocked(localStorage.getItem).mockImplementation((key) => {
+ if (key.endsWith('cache-preferences-custom')) {
+ return JSON.stringify({
+ value: {
+ pageSize: 5,
+ },
+ });
+ }
+
+ return null;
+ });
+
+ await preferenceManager.initPreferences({
+ extension: {
+ fields: [
+ {
+ component: 'number',
+ componentProps: {
+ max: 10,
+ min: 2,
+ step: 2,
+ },
+ defaultValue: 4,
+ key: 'pageSize',
+ label: '分页大小',
+ },
+ ],
+ tabLabel: '扩展',
+ },
+ namespace: 'custom-number-cache',
+ });
+
+ expect(preferenceManager.getCustomPreferences()).toEqual({
+ pageSize: 4,
+ });
+ });
+});
+
+describe('isDarkTheme', () => {
+ it('should return true for dark theme', () => {
+ expect(isDarkTheme('dark')).toBe(true);
+ });
+
+ it('should return false for light theme', () => {
+ expect(isDarkTheme('light')).toBe(false);
+ });
+
+ it('should return system preference for auto theme', () => {
+ vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
+ addEventListener: vi.fn(),
+ addListener: vi.fn(), // Deprecated
+ dispatchEvent: vi.fn(),
+ matches: query === '(prefers-color-scheme: dark)',
+ media: query,
+ onchange: null,
+ removeEventListener: vi.fn(),
+ removeListener: vi.fn(), // Deprecated
+ }));
+
+ expect(isDarkTheme('auto')).toBe(true);
+ expect(window.matchMedia).toHaveBeenCalledWith(
+ '(prefers-color-scheme: dark)',
+ );
+ });
+});
diff --git a/deploy/fba/fba-ui-src/packages/@core/preferences/package.json b/deploy/fba/fba-ui-src/packages/@core/preferences/package.json
new file mode 100644
index 0000000..3a92f92
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/preferences/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@vben-core/preferences",
+ "version": "5.7.0",
+ "homepage": "https://github.com/vbenjs/vue-vben-admin",
+ "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/vbenjs/vue-vben-admin.git",
+ "directory": "packages/@core/preferences"
+ },
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "#build": "pnpm exec tsdown"
+ },
+ "files": [
+ "dist",
+ "src"
+ ],
+ "sideEffects": [
+ "**/*.css"
+ ],
+ "main": "./dist/index.mjs",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "development": "./src/index.ts",
+ "production": "./src/index.ts",
+ "default": "./dist/index.mjs"
+ }
+ },
+ "publishConfig": {
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.mjs"
+ }
+ }
+ },
+ "dependencies": {
+ "@vben-core/shared": "workspace:*",
+ "@vben-core/typings": "workspace:*",
+ "@vueuse/core": "catalog:",
+ "vue": "catalog:"
+ }
+}
diff --git a/deploy/fba/fba-ui-src/packages/@core/preferences/src/config.ts b/deploy/fba/fba-ui-src/packages/@core/preferences/src/config.ts
new file mode 100644
index 0000000..d776282
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/preferences/src/config.ts
@@ -0,0 +1,149 @@
+import type { Preferences } from './types';
+
+const defaultPreferences: Preferences = {
+ app: {
+ accessMode: 'frontend',
+ authPageLayout: 'panel-right',
+ checkUpdatesInterval: 1,
+ colorGrayMode: false,
+ colorWeakMode: false,
+ compact: false,
+ contentCompact: 'wide',
+ contentCompactWidth: 1200,
+ contentPadding: 0,
+ contentPaddingBottom: 0,
+ contentPaddingLeft: 0,
+ contentPaddingRight: 0,
+ contentPaddingTop: 0,
+ defaultAvatar:
+ 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp',
+ defaultHomePath: '/analytics',
+ dynamicTitle: true,
+ enableCheckUpdates: true,
+ enableCopyPreferences: true,
+ enablePreferences: true,
+ enableRefreshToken: false,
+ enableStickyPreferencesNavigationBar: true,
+ isMobile: false,
+ layout: 'sidebar-nav',
+ locale: 'zh-CN',
+ loginExpiredMode: 'page',
+ name: 'Vben Admin',
+ preferencesButtonPosition: 'auto',
+ timezone: 'Asia/Shanghai',
+ watermark: false,
+ watermarkContent: '',
+ zIndex: 200,
+ },
+ breadcrumb: {
+ enable: true,
+ hideOnlyOne: false,
+ showHome: false,
+ showIcon: true,
+ styleType: 'normal',
+ },
+ copyright: {
+ companyName: 'Vben',
+ companySiteLink: 'https://www.vben.pro',
+ date: '2024',
+ enable: true,
+ icp: '',
+ icpLink: '',
+ settingShow: true,
+ },
+ footer: {
+ enable: false,
+ fixed: false,
+ height: 32,
+ },
+ header: {
+ enable: true,
+ height: 50,
+ hidden: false,
+ menuAlign: 'start',
+ mode: 'fixed',
+ },
+
+ logo: {
+ enable: true,
+ fit: 'contain',
+ source: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
+ },
+ navigation: {
+ accordion: true,
+ split: true,
+ styleType: 'rounded',
+ },
+ shortcutKeys: {
+ enable: true,
+ globalEscape: false,
+ globalLockScreen: true,
+ globalLogout: true,
+ globalPreferences: true,
+ globalSearch: true,
+ },
+ sidebar: {
+ autoActivateChild: false,
+ collapsed: false,
+ collapsedButton: true,
+ collapsedShowTitle: false,
+ collapseWidth: 60,
+ draggable: true,
+ enable: true,
+ expandOnHover: true,
+ extraCollapse: false,
+ extraCollapsedWidth: 60,
+ fixedButton: true,
+ hidden: false,
+ mixedWidth: 80,
+ width: 224,
+ },
+ tabbar: {
+ draggable: true,
+ enable: true,
+ height: 38,
+ keepAlive: true,
+ maxCount: 0,
+ middleClickToClose: false,
+ persist: true,
+ showIcon: true,
+ showMaximize: true,
+ showMore: true,
+ showRefresh: true,
+ styleType: 'chrome',
+ visitHistory: true,
+ wheelable: true,
+ },
+ theme: {
+ builtinType: 'default',
+ colorDestructive: 'hsl(348 100% 61%)',
+ colorPrimary: 'hsl(212 100% 45%)',
+ colorSuccess: 'hsl(144 57% 58%)',
+ colorWarning: 'hsl(42 84% 61%)',
+ mode: 'dark',
+ radius: '0.5',
+ fontSize: 16,
+ semiDarkHeader: false,
+ semiDarkSidebar: false,
+ semiDarkSidebarSub: false,
+ },
+ transition: {
+ enable: true,
+ loading: true,
+ name: 'fade-slide',
+ progress: true,
+ },
+ widget: {
+ fullscreen: true,
+ globalSearch: true,
+ languageToggle: true,
+ lockScreen: true,
+ notification: true,
+ refresh: true,
+ sidebarToggle: true,
+ themeToggle: true,
+ timezone: true,
+ },
+};
+
+export { defaultPreferences };
diff --git a/deploy/fba/fba-ui-src/packages/@core/preferences/src/constants.ts b/deploy/fba/fba-ui-src/packages/@core/preferences/src/constants.ts
new file mode 100644
index 0000000..562a7af
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/preferences/src/constants.ts
@@ -0,0 +1,116 @@
+import type { BuiltinThemeType, TimezoneOption } from '@vben-core/typings';
+
+interface BuiltinThemePreset {
+ color: string;
+ darkPrimaryColor?: string;
+ primaryColor?: string;
+ type: BuiltinThemeType;
+}
+
+const BUILT_IN_THEME_PRESETS: BuiltinThemePreset[] = [
+ {
+ color: 'hsl(212 100% 45%)',
+ type: 'default',
+ },
+ {
+ color: 'hsl(245 82% 67%)',
+ type: 'violet',
+ },
+ {
+ color: 'hsl(347 77% 60%)',
+ type: 'pink',
+ },
+ {
+ color: 'hsl(42 84% 61%)',
+ type: 'yellow',
+ },
+ {
+ color: 'hsl(231 98% 65%)',
+ type: 'sky-blue',
+ },
+ {
+ color: 'hsl(161 90% 43%)',
+ type: 'green',
+ },
+ {
+ color: 'hsl(240 5% 26%)',
+ darkPrimaryColor: 'hsl(0 0% 98%)',
+ primaryColor: 'hsl(240 5.9% 10%)',
+ type: 'zinc',
+ },
+ {
+ color: 'hsl(181 84% 32%)',
+ type: 'deep-green',
+ },
+ {
+ color: 'hsl(211 91% 39%)',
+ type: 'deep-blue',
+ },
+ {
+ color: 'hsl(18 89% 40%)',
+ type: 'orange',
+ },
+ {
+ color: 'hsl(0 75% 42%)',
+ type: 'rose',
+ },
+ {
+ color: 'hsl(0 0% 25%)',
+ darkPrimaryColor: 'hsl(0 0% 98%)',
+ primaryColor: 'hsl(240 5.9% 10%)',
+ type: 'neutral',
+ },
+ {
+ color: 'hsl(215 25% 27%)',
+ darkPrimaryColor: 'hsl(0 0% 98%)',
+ primaryColor: 'hsl(240 5.9% 10%)',
+ type: 'slate',
+ },
+ {
+ color: 'hsl(217 19% 27%)',
+ darkPrimaryColor: 'hsl(0 0% 98%)',
+ primaryColor: 'hsl(240 5.9% 10%)',
+ type: 'gray',
+ },
+ {
+ color: '',
+ type: 'custom',
+ },
+];
+
+/**
+ * 时区选项
+ */
+const DEFAULT_TIME_ZONE_OPTIONS: TimezoneOption[] = [
+ {
+ offset: -5,
+ timezone: 'America/New_York',
+ label: 'America/New_York(GMT-5)',
+ },
+ {
+ offset: 0,
+ timezone: 'Europe/London',
+ label: 'Europe/London(GMT0)',
+ },
+ {
+ offset: 8,
+ timezone: 'Asia/Shanghai',
+ label: 'Asia/Shanghai(GMT+8)',
+ },
+ {
+ offset: 9,
+ timezone: 'Asia/Tokyo',
+ label: 'Asia/Tokyo(GMT+9)',
+ },
+ {
+ offset: 9,
+ timezone: 'Asia/Seoul',
+ label: 'Asia/Seoul(GMT+9)',
+ },
+];
+
+export const COLOR_PRESETS = [...BUILT_IN_THEME_PRESETS].slice(0, 7);
+
+export { BUILT_IN_THEME_PRESETS, DEFAULT_TIME_ZONE_OPTIONS };
+
+export type { BuiltinThemePreset };
diff --git a/deploy/fba/fba-ui-src/packages/@core/preferences/src/index.ts b/deploy/fba/fba-ui-src/packages/@core/preferences/src/index.ts
new file mode 100644
index 0000000..91eb106
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/preferences/src/index.ts
@@ -0,0 +1,24 @@
+/* oxlint-disable unicorn/prefer-export-from */
+import type { Preferences } from './types';
+
+import { preferencesManager } from './preferences';
+
+export const {
+ getPreferences,
+ getCustomPreferences,
+ getInitialCustomPreferences,
+ getPreferencesExtension,
+ updatePreferences,
+ updateCustomPreferences,
+ resetPreferences,
+ clearCache,
+ initPreferences,
+} = preferencesManager;
+
+export const preferences: Preferences = getPreferences();
+
+export { preferencesManager };
+
+export * from './constants';
+export type * from './types';
+export * from './use-preferences';
diff --git a/deploy/fba/fba-ui-src/packages/@core/preferences/src/preferences.ts b/deploy/fba/fba-ui-src/packages/@core/preferences/src/preferences.ts
new file mode 100644
index 0000000..c7f5caa
--- /dev/null
+++ b/deploy/fba/fba-ui-src/packages/@core/preferences/src/preferences.ts
@@ -0,0 +1,464 @@
+import type { DeepPartial } from '@vben-core/typings';
+
+import type {
+ CustomPreferencesField,
+ CustomPreferencesRecord,
+ InitialOptions,
+ Preferences,
+ PreferencesExtension,
+} from './types';
+
+import { markRaw, reactive, readonly, watch } from 'vue';
+
+import { StorageManager } from '@vben-core/shared/cache';
+import { isMacOs, merge } from '@vben-core/shared/utils';
+
+import {
+ breakpointsTailwind,
+ useBreakpoints,
+ useDebounceFn,
+} from '@vueuse/core';
+
+import { defaultPreferences } from './config';
+import { updateCSSVariables } from './update-css-variables';
+
+const STORAGE_KEYS = {
+ CUSTOM: 'preferences-custom',
+ MAIN: 'preferences',
+ LOCALE: 'preferences-locale',
+ THEME: 'preferences-theme',
+} as const;
+
+class PreferenceManager {
+ private cache: StorageManager;
+ private customPreferencesExtension: null | PreferencesExtension = null;
+ private customState = reactive({});
+ private debouncedSave: () => void;
+ private initialCustomPreferences: CustomPreferencesRecord = {};
+ private initialPreferences: Preferences = defaultPreferences;
+ private isInitialized = false;
+ private state: Preferences;
+
+ constructor() {
+ this.cache = new StorageManager();
+ // 构造函数不再同步读取缓存,使用默认值初始化
+ // 真正的缓存加载在 initPreferences 中完成(已经是 async)
+ this.state = reactive({ ...defaultPreferences });
+ this.debouncedSave = useDebounceFn(() => this.saveToCache(), 150);
+ }
+
+ /**
+ * 清除所有缓存的偏好设置
+ */
+ clearCache = async () => {
+ await Promise.all(
+ Object.values(STORAGE_KEYS).map((key) => this.cache.removeItem(key)),
+ );
+ };
+
+ /**
+ * 获取扩展偏好设置
+ */
+ getCustomPreferences = <
+ TCustomPreferences extends object = CustomPreferencesRecord,
+ >() => {
+ return readonly(this.customState) as Readonly;
+ };
+
+ /**
+ * 获取初始化扩展偏好设置
+ */
+ getInitialCustomPreferences = <
+ TCustomPreferences extends object = CustomPreferencesRecord,
+ >() => {
+ return this.cloneValue(
+ this.initialCustomPreferences,
+ ) as Readonly;
+ };
+
+ /**
+ * 获取初始化偏好设置
+ */
+ getInitialPreferences = () => {
+ return this.initialPreferences;
+ };
+
+ /**
+ * 获取当前偏好设置(只读)
+ */
+ getPreferences = () => {
+ return readonly(this.state);
+ };
+
+ /**
+ * 获取扩展偏好设置配置
+ */
+ getPreferencesExtension = <
+ TCustomPreferences extends object = CustomPreferencesRecord,
+ >() => {
+ return this.customPreferencesExtension
+ ? (this.cloneValue(this.customPreferencesExtension) as Readonly<
+ PreferencesExtension
+ >)
+ : null;
+ };
+
+ /**
+ * 初始化偏好设置
+ * @param options - 初始化配置项
+ * @param options.namespace - 命名空间,用于隔离不同应用的配置
+ * @param options.overrides - 要覆盖的偏好设置
+ */
+ initPreferences = async <
+ TCustomPreferences extends object = CustomPreferencesRecord,
+ >({
+ namespace,
+ overrides,
+ extension,
+ }: InitialOptions) => {
+ // 防止重复初始化
+ if (this.isInitialized) {
+ return;
+ }
+
+ // 使用命名空间初始化存储管理器
+ this.cache = new StorageManager({ prefix: namespace });
+
+ // 合并初始偏好设置:前面的对象优先,后面的对象仅补齐缺失字段
+ this.initialPreferences = merge({}, overrides, defaultPreferences);
+ this.customPreferencesExtension = extension ?? null;
+ this.initialCustomPreferences = this.resolveCustomPreferencesDefaults(
+ this.customPreferencesExtension,
+ );
+
+ // 加载缓存的偏好设置,并仅用缓存补齐初始化配置中未显式设置的字段
+ const cachedPreferences = (await this.loadFromCache()) || {};
+ const mergedPreference = merge(
+ {},
+ cachedPreferences, // 用户缓存的设置优先
+ this.initialPreferences, // 初始设置仅补齐缺失字段
+ );
+
+ // 更新偏好设置
+ this.updatePreferences(mergedPreference);
+
+ const cachedCustom = (await this.loadCustomFromCache()) || {};
+ this.replaceCustomPreferences(
+ merge(
+ {},
+ this.sanitizeCustomPreferences(cachedCustom),
+ this.initialCustomPreferences,
+ ),
+ );
+ await this.saveToCache();
+
+ // 设置监听器
+ this.setupWatcher();
+
+ // 初始化平台标识
+ this.initPlatform();
+
+ this.isInitialized = true;
+ };
+
+ /**
+ * 重置偏好设置到初始状态
+ */
+ resetPreferences = async () => {
+ // 将状态重置为初始偏好设置
+ Object.assign(this.state, this.initialPreferences);
+ this.replaceCustomPreferences(this.initialCustomPreferences);
+
+ // 保存偏好设置至缓存
+ await this.saveToCache();
+
+ // 直接触发 UI 更新
+ this.handleUpdates(this.state);
+ };
+
+ /**
+ * 更新扩展偏好设置
+ * @param updates - 要更新的扩展偏好设置
+ */
+ updateCustomPreferences = (updates: DeepPartial]