chore: vendor FBA UI 工程源码(fastapi-best-architecture-ui master,Epic #163 受控源码基础)

This commit is contained in:
2026-08-06 12:50:35 +08:00
parent 9728fa40fe
commit 88d84757ac
1166 changed files with 109805 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11
+1
View File
@@ -0,0 +1 @@
export { default } from '@vben/commitlint-config';
+7
View File
@@ -0,0 +1,7 @@
node_modules
.git
.gitignore
*.md
dist
.turbo
dist.zip
+18
View File
@@ -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
+11
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
[core]
ignorecase = false
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.local
+20
View File
@@ -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',
],
};
+1
View File
@@ -0,0 +1 @@
24.16.0
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com
+8
View File
@@ -0,0 +1,8 @@
dist
public
__tests__
coverage
.codex
.claude
.agent
.agents
+18
View File
@@ -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;"]
+21
View File
@@ -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.
+23
View File
@@ -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
<a href="https://github.com/fastapi-practices/fba_ui/graphs/contributors">
<img src="https://contrib.rocks/image?repo=fastapi-practices/fba_ui"/>
</a>
## 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
@@ -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
@@ -0,0 +1,7 @@
# public path
VITE_BASE=/
# Basic interface address SPA
VITE_GLOB_API_URL=http://localhost:8000
VITE_VISUALIZER=true
@@ -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
@@ -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
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="renderer" content="webkit" />
<meta name="description" content="A Modern Back-end Management System" />
<meta name="keywords" content="Vben Admin Vue3 Vite" />
<meta name="author" content="Vben" />
<meta name="google-site-verification" content="......." />
<meta name="fba" content="Enterprise-level backend architecture solution" />
<meta
name="viewport"
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
/>
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
<title>%VITE_APP_TITLE%</title>
<link rel="icon" href="/favicon.ico" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -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:"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -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<any> = {},
) => {
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<string> {
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<boolean>,
fileList: Ref<UploadProps['fileList']>,
) {
// 非图片文件直接打开链接
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<Blob | string | undefined>((resolve, reject) => {
const container = document.createElement('div');
document.body.append(container);
let isUnmounted = false;
let objectUrl: null | string = null;
const open = ref<boolean>(true);
const cropperRef = ref<InstanceType<typeof VCropper> | 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<boolean>(false);
const placeholder = attrs?.placeholder || $t('ui.placeholder.upload');
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
const fileList = ref<UploadProps['fileList']>(
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<File>,
) {
// 文件大小限制
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 | Sortable>(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<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载
// 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 };
@@ -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<ComponentType>({
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<ComponentType, ComponentPropsMap>;
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
@@ -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<Recordable<any>> = {
delete: {
danger: true,
text: $t('common.delete'),
},
edit: {
text: $t('common.edit'),
},
};
const operations: Array<Recordable<any>> = (
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<any> = {};
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<any>, 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<any>) {
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<any>) {
const menuItems =
opt.items?.map((item: Recordable<any>) => ({
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 = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export type OnActionClickParams<T = Recordable<any>> = {
code: string;
row: T;
};
export type OnActionClickFn<T = Recordable<any>> = (
params: OnActionClickParams<T>,
) => void;
export type * from '@vben/plugins/vxe-table';
@@ -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<CaptchaResult>('/api/v1/auth/captcha');
}
/**
* 登录
*/
export async function loginApi(data: LoginParams) {
return requestClient.post<LoginResult>('/api/v1/auth/login', data);
}
/**
* 刷新accessToken
*/
export async function refreshTokenApi() {
return miniRequestClient.post<RefreshTokenResult>(
'/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<string[]>('/api/v1/auth/codes');
}
@@ -0,0 +1,4 @@
export * from './auth';
export * from './menu';
export * from './upload';
export * from './user';
@@ -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<RouteRecordStringComponent[]>(
'/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<SysMenuTreeResult[]>(
'/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}`);
}
@@ -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)));
}
}
@@ -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<MyUserInfo>('/api/v1/sys/users/me');
}
export async function getSysUserListApi(params: SysUserParams) {
return requestClient.get<SysUserResult>('/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}`);
}
@@ -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<SysDataScopeResult[]>('/api/v1/sys/data-scopes', {
params,
});
}
export async function getSysDataScopesApi() {
return requestClient.get<SysDataScopeResult[]>('/api/v1/sys/data-scopes/all');
}
export async function getSysDataScopeRulesApi(pk: number) {
return requestClient.get<SysDataScopeRulesResult>(
`/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<SysDataRuleResult[]>('/api/v1/sys/data-rules', {
params,
});
}
export async function getSysDataRulesApi() {
return requestClient.get<SysDataRuleResult[]>('/api/v1/sys/data-rules/all');
}
export async function getSysDataRuleModelsApi() {
return requestClient.get<string[]>('/api/v1/sys/data-rules/models');
}
export async function getSysDataRuleModelColumnsApi(model: string) {
return requestClient.get<SysDataRuleModelColumnsResult[]>(
`/api/v1/sys/data-rules/models/${model}/columns`,
);
}
export async function getSysDataRuleTemplateVariablesApi() {
return requestClient.get<SysDataRuleTemplateVariableResult[]>(
'/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 } });
}
@@ -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<SysDeptTreeResult[]>('/api/v1/sys/depts', {
params,
});
}
/**
* 获取部门详情
*/
export async function getSysDeptDetailApi(pk: number) {
return requestClient.get<SysDeptTreeResult>(`/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}`);
}
@@ -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';
@@ -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<PaginationResult<LoginLogResult>>(
'/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<PaginationResult<OperaLogResult>>(
'/api/v1/logs/opera',
{ params },
);
}
export async function deleteOperaLogApi(pks: number[]) {
return requestClient.delete('/api/v1/logs/opera', { data: { pks } });
}
@@ -0,0 +1,57 @@
import { requestClient } from './request';
export interface ServerMonitorResult {
cpu: Record<string, any>;
mem: Record<string, any>;
sys: Record<string, any>;
disk: Record<string, any>[];
service: Record<string, any>;
}
export interface RedisMonitorResult {
info: Record<string, any>;
stats: Record<string, any>[];
}
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<ServerMonitorResult>('/api/v1/monitors/server');
}
export async function getRedisMonitorApi() {
return requestClient.get<RedisMonitorResult>('/api/v1/monitors/redis');
}
export async function getOnlineMonitorApi(params: MonitorOnlineParams) {
return requestClient.get<OnlineMonitorResult[]>('/api/v1/monitors/sessions', {
params,
});
}
export async function kickOutOnlineApi(
pk: number,
params: KickOutOnlineParams,
) {
return requestClient.delete(`/api/v1/monitors/sessions/${pk}`, { params });
}
@@ -0,0 +1,39 @@
import { requestClient } from '#/api/request';
export interface PluginResult {
[key: string]: any;
}
export async function getPluginListApi() {
return requestClient.get<PluginResult[]>('/api/v1/sys/plugins');
}
export async function getPluginChangedApi() {
return requestClient.get<boolean>('/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<Blob>(`/api/v1/sys/plugins/${plugin}`);
}
export async function uninstallPluginApi(plugin: string) {
return await requestClient.delete(`/api/v1/sys/plugins/${plugin}`);
}
@@ -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 });
@@ -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<SysRoleResult[]>('/api/v1/sys/roles', { params });
}
export async function getAllSysRoleApi() {
return requestClient.get<SysRoleResult[]>('/api/v1/sys/roles/all');
}
export async function getSysRoleMenuApi(pk: number) {
return requestClient.get<RouteRecordStringComponent[]>(
`/api/v1/sys/roles/${pk}/menus`,
);
}
export async function getSysRoleDataScopesApi(pk: number) {
return requestClient.get<number[]>(`/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 } });
}
@@ -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<TaskResult>(`/api/v1/task-results/${pk}`);
}
export async function getTaskResultListApi(params?: TaskResultParams) {
return requestClient.get<PaginationResult<TaskSchedulerResult>>(
'/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<TaskSchedulerResult[]>('/api/v1/schedulers/all');
}
export async function getTaskSchedulerListApi(params?: TaskSchedulerParams) {
return requestClient.get<PaginationResult<TaskSchedulerResult>>(
'/api/v1/schedulers',
{ params },
);
}
export async function getTaskSchedulerApi(pk: number) {
return requestClient.get<TaskSchedulerResult>(`/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<any[]>('/api/v1/tasks/registered');
}
export async function revokeTaskSchedulerApi(task_id: string) {
return requestClient.delete(`/api/v1/tasks/${task_id}/cancel`);
}
@@ -0,0 +1,47 @@
<script lang="ts" setup>
import { computed, watch } from 'vue';
import { useAntdDesignTokens } from '@vben/hooks';
import { preferences, usePreferences } from '@vben/preferences';
import { App, ConfigProvider, theme } from 'antdv-next';
import { antdLocale } from '#/locales';
defineOptions({ name: 'App' });
const { isDark } = usePreferences();
const { tokens } = useAntdDesignTokens();
const tokenTheme = computed(() => {
const algorithm = isDark.value
? [theme.darkAlgorithm]
: [theme.defaultAlgorithm];
// antd 紧凑模式算法
if (preferences.app.compact) {
algorithm.push(theme.compactAlgorithm);
}
return {
algorithm,
token: tokens,
};
});
watch(
tokenTheme,
(themeConfig) => {
ConfigProvider.config({ theme: themeConfig });
},
{ immediate: true },
);
</script>
<template>
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
<App>
<RouterView />
</App>
</ConfigProvider>
</template>
@@ -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 };
@@ -0,0 +1,25 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { AuthPageLayout } from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { $t } from '#/locales';
const appName = computed(() => preferences.app.name);
const logo = computed(() => preferences.logo.source);
const logoDark = computed(() => preferences.logo.sourceDark);
</script>
<template>
<AuthPageLayout
:app-name="appName"
:logo="logo"
:logo-dark="logoDark"
:page-description="$t('authentication.pageDesc')"
:page-title="$t('authentication.pageTitle')"
>
<!-- 自定义工具栏 -->
<!-- <template #toolbar></template> -->
</AuthPageLayout>
</template>
@@ -0,0 +1,256 @@
<script lang="ts" setup>
import type { NotificationItem } from '@vben/layouts';
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
import { useWatermark } from '@vben/hooks';
import { BookOpenText, CircleHelp, SvgGithubIcon } from '@vben/icons';
import {
BasicLayout,
LockScreen,
Notification,
UserDropdown,
} from '@vben/layouts';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
import { $t } from '#/locales';
import { useAuthStore } from '#/store';
import LoginForm from '#/views/_core/authentication/login.vue';
const notifications = ref<NotificationItem[]>([
{
id: 1,
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
date: '3小时前',
isRead: true,
message: '描述信息描述信息描述信息',
title: '收到了 14 份新周报',
},
{
id: 2,
avatar: 'https://avatar.vercel.sh/1',
date: '刚刚',
isRead: false,
message: '描述信息描述信息描述信息',
title: '朱偏右 回复了你',
},
{
id: 3,
avatar: 'https://avatar.vercel.sh/1',
date: '2024-01-01',
isRead: false,
message: '描述信息描述信息描述信息',
title: '曲丽丽 评论了你',
},
{
id: 4,
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '代办提醒',
},
{
id: 5,
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '跳转Workspace示例',
link: '/workspace',
},
{
id: 6,
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '跳转外部链接示例',
link: 'https://doc.vben.pro',
},
]);
const router = useRouter();
const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark();
const { isDark } = usePreferences();
const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
const menus = computed(() => [
{
handler: () => {
router.push({ name: 'Profile' });
},
icon: 'lucide:user',
text: $t('page.auth.profile'),
},
{
handler: () => {
openWindow(VBEN_DOC_URL, {
target: '_blank',
});
},
icon: BookOpenText,
text: $t('ui.widgets.document'),
},
{
handler: () => {
openWindow(VBEN_GITHUB_URL, {
target: '_blank',
});
},
icon: SvgGithubIcon,
text: 'GitHub',
},
{
handler: () => {
openWindow(`${VBEN_GITHUB_URL}/issues`, {
target: '_blank',
});
},
icon: CircleHelp,
text: $t('ui.widgets.qa'),
},
]);
const avatar = computed(() => {
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
});
async function handleLogout() {
await authStore.logout(false);
}
function handleNoticeClear() {
notifications.value = [];
}
function markRead(id: number | string) {
const item = notifications.value.find((item) => item.id === id);
if (item) {
item.isRead = true;
}
}
function remove(id: number | string) {
notifications.value = notifications.value.filter((item) => item.id !== id);
}
function handleMakeAll() {
notifications.value.forEach((item) => (item.isRead = true));
}
const viewAll = () => {};
const handleClick = (item: NotificationItem) => {
// 如果通知项有链接,点击时跳转
if (item.link) {
navigateTo(item.link, item.query, item.state);
}
};
function navigateTo(
link: string,
query?: Record<string, any>,
state?: Record<string, any>,
) {
if (link.startsWith('http://') || link.startsWith('https://')) {
// 外部链接,在新标签页打开
window.open(link, '_blank');
} else {
// 内部路由链接,支持 query 参数和 state
router.push({
path: link,
query: query || {},
state,
});
}
}
watch(
() => ({
enable: preferences.app.watermark,
content: preferences.app.watermarkContent,
isDark: isDark.value,
}),
async ({ enable, content, isDark: isDarkValue }) => {
if (enable) {
const watermarkColor = isDarkValue
? 'rgba(255, 255, 255, 0.12)'
: 'rgba(0, 0, 0, 0.12)';
await updateWatermark({
advancedStyle: {
colorStops: [
{
color: watermarkColor,
offset: 0,
},
{
color: watermarkColor,
offset: 1,
},
],
type: 'linear',
},
content:
content ||
`${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
});
} else {
destroyWatermark();
}
},
{
immediate: true,
},
);
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<template #user-dropdown>
<UserDropdown
:avatar
:menus
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
/>
</template>
<template #notification>
<Notification
:dot="showDot"
:notifications="notifications"
@clear="handleNoticeClear"
@read="(item) => item.id && markRead(item.id)"
@remove="(item) => item.id && remove(item.id)"
@make-all="handleMakeAll"
@on-click="handleClick"
@view-all="viewAll"
/>
</template>
<template #extra>
<AuthenticationLoginExpiredModal
v-model:open="accessStore.loginExpired"
:avatar
>
<LoginForm />
</AuthenticationLoginExpiredModal>
</template>
<template #lock-screen>
<LockScreen :avatar @to-login="handleLogout" />
</template>
</BasicLayout>
</template>
@@ -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 };
@@ -0,0 +1,3 @@
# locale
每个app使用的国际化可能不同,这里用于扩展国际化的功能,例如扩展 dayjs、antd组件库的多语言切换,以及app本身的国际化文件。
@@ -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<Locale>(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 };
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
}
}
@@ -0,0 +1,14 @@
{
"form": {
"query": "查询",
"select": "请选择",
"status": "状态"
},
"table": {
"created_time": "创建时间",
"id": "序号",
"mark": "备注",
"operation": "操作",
"updated_time": "更新时间"
}
}
@@ -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 版本"
}
}
@@ -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": "系统架构"
}
}
}
}
@@ -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();
@@ -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);
}
@@ -0,0 +1,6 @@
{
"name": "@fba-ui/aliyun-sms",
"version": "5.7.0",
"private": true,
"type": "module"
}
@@ -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<CodeGenBusinessResult>(
`/api/v1/code-generation/businesses/${pk}`,
);
}
export async function getAllCodeGenBusinessApi() {
return requestClient.get<CodeGenBusinessResult[]>(
'/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<PaginationResult<CodeGenBusinessResult>>(
`/api/v1/code-generation/businesses`,
{ params },
);
}
export async function getAllCodeGenBusinessColumnApi(pk: number) {
return requestClient.get<PaginationResult<CodeGenColumnResult>>(
`/api/v1/code-generation/businesses/${pk}/columns`,
);
}
export async function getAllCodeGenColumnTypeApi() {
return requestClient.get<string[]>(`/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<any>) {
return requestClient.get<string[]>(
`/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<string[]>(
`/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<Blob>(
`/api/v1/code-generation/generations/${pk}`,
);
}
@@ -0,0 +1,3 @@
{
"menu": "Code Generator"
}
@@ -0,0 +1,3 @@
{
"menu": "代码生成"
}
@@ -0,0 +1,6 @@
{
"name": "@fba-ui/code-generator",
"version": "5.7.0",
"private": true,
"type": "module"
}
@@ -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;
@@ -0,0 +1,266 @@
<script lang="ts" setup>
import type { CodeGenColumnParams, CodeGenColumnResult } from '../api';
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import { computed, h, ref } from 'vue';
import {
confirm,
useVbenDrawer,
useVbenModal,
VbenButton,
} from '@vben/common-ui';
import { MaterialSymbolsAdd } from '@vben/icons';
import { $t } from '@vben/locales';
import { downloadFileFromBlob } from '@vben/utils';
import { Alert, message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
createCodeGenColumnApi,
deleteCodeGenColumnApi,
downloadCodeApi,
generateCodeApi,
getAllCodeGenBusinessColumnApi,
getCodeGenPathApi,
updateCodeGenColumnApi,
} from '../api';
import { columnSchema, useColumnColumns } from './data';
import ExtraModal from './preview.vue';
const generateLoading = ref(false);
const gridOptions: VxeTableGridOptions<CodeGenColumnResult[]> = {
rowConfig: {
keyField: 'id',
},
height: 'auto',
virtualYConfig: {
enabled: true,
},
pagerConfig: {
enabled: false,
},
columns: useColumnColumns(onActionClick),
proxyConfig: {
autoLoad: false,
ajax: {
query: async () => {
return await getAllCodeGenBusinessColumnApi(drawerApi.getData().pk);
},
},
},
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
function onActionClick({
code,
row,
}: OnActionClickParams<CodeGenColumnResult>) {
switch (code) {
case 'delete': {
deleteCodeGenColumnApi(row.id).then(() => {
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_process_msg',
});
onRefresh();
});
break;
}
case 'edit': {
modalApi.setData(row).open();
break;
}
}
}
function onRefresh() {
gridApi.query();
}
const [Drawer, drawerApi] = useVbenDrawer({
destroyOnClose: true,
closable: false,
cancelText: '预览',
confirmText: '下载',
class: 'w-2/3',
onOpenChange(isOpen) {
if (isOpen) {
gridApi.setLoading(true);
}
},
async onOpened() {
try {
await gridApi.query();
} finally {
gridApi.setLoading(false);
}
},
onCancel: () => {
previewModalApi.setData({ pk: drawerApi.getData().pk }).open();
},
onConfirm: async () => {
drawerApi.setState({ confirmLoading: true });
try {
const res = await downloadCodeApi(drawerApi.getData().pk);
downloadFileFromBlob({ fileName: 'fba_generator', source: res });
message.success('代码包已开始下载');
} catch (error) {
console.error(error);
} finally {
drawerApi.setState({ confirmLoading: false });
}
},
});
async function showGenerate() {
const paths = await getCodeGenPathApi(drawerApi.getData().pk);
confirm({
title: '警告',
content: h('div', null, [
h(Alert, {
type: 'error',
showIcon: false,
message:
'代码生成将进行磁盘文件(覆盖)写入,切勿在生产环境中使用!!!',
}),
h('br'),
h('div', { class: 'rounded border border-solid border-gray-300' }, [
h(
'div',
{
class:
'border-b border-solid border-gray-300 bg-gray-50 px-4 py-2 font-medium',
},
'即将写入以下文件:',
),
h(
'ul',
{ class: 'list-none p-0 m-0' },
paths.map((item: string) =>
h(
'li',
{
class:
'border-b border-solid border-gray-200 px-4 py-2 last:border-b-0',
},
h('p', { class: 'text-orange-500 m-0' }, item),
),
),
),
]),
]),
icon: 'error',
confirmText: '不怂!就是干',
})
.then(async () => {
generateLoading.value = true;
try {
await generateCodeApi(drawerApi.getData().pk);
message.success($t('ui.actionMessage.operationSuccess'));
} catch (error) {
console.error(error);
} finally {
generateLoading.value = false;
}
})
.catch(() => {});
}
interface formCodeGenColumnParams extends CodeGenColumnParams {
id?: number;
}
const formData = ref<formCodeGenColumnParams>();
const modalTile = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['模型列'])
: $t('ui.actionTitle.create', ['模型列']);
});
const [Form, formApi] = useVbenForm({
showDefaultActions: false,
schema: columnSchema,
});
const [Modal, modalApi] = useVbenModal({
destroyOnClose: true,
class: 'w-1/3',
async onConfirm() {
const { valid } = await formApi.validate();
if (valid) {
modalApi.lock();
const data = await formApi.getValues<CodeGenColumnParams>();
try {
await (formData.value?.id
? updateCodeGenColumnApi(formData.value.id, data)
: createCodeGenColumnApi(data));
message.success($t('ui.actionMessage.operationSuccess'));
await modalApi.close();
onRefresh();
} finally {
modalApi.unlock();
}
}
},
onOpenChange(isOpen) {
if (isOpen) {
const data = modalApi.getData<formCodeGenColumnParams>();
formApi.resetForm();
if (data) {
formData.value = data;
formApi.setValues(formData.value);
if (!formData.value.id) {
formApi.setFieldValue('gen_business_id', drawerApi.getData().pk);
}
}
}
},
});
const [PreviewModal, previewModalApi] = useVbenModal({
connectedComponent: ExtraModal,
});
</script>
<template>
<Drawer title="业务模型列">
<Grid />
<template #extra>
<a-alert :show-icon="false">
<template #title>
主键 ID 列状态:<a-tag color="success">自动生成</a-tag>
默认时间列状态:
<a-tag
:color="drawerApi.getData().datetime_mixin ? 'success' : 'warning'"
>
{{ drawerApi.getData().datetime_mixin ? '已配置' : '未配置' }}
</a-tag>
</template>
</a-alert>
<VbenButton @click="modalApi.setData(null).open()" class="ml-3">
<MaterialSymbolsAdd class="size-5" />
新增模型列
</VbenButton>
</template>
<template #center-footer>
<a-button :loading="generateLoading" type="primary" @click="showGenerate">
生成
</a-button>
</template>
</Drawer>
<Modal :title="modalTile">
<Form />
</Modal>
<PreviewModal />
</template>
@@ -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<CodeGenBusinessResult>,
): 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<string>();
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<CodeGenColumnResult>,
): 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',
},
];
@@ -0,0 +1,224 @@
<script lang="ts" setup>
import type { VbenFormProps } from '@vben/common-ui';
import type {
CodeGenBusinessImportParams,
CodeGenBusinessParams,
CodeGenBusinessResult,
} from '../api';
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import { computed, ref } from 'vue';
import { Page, useVbenDrawer, useVbenModal, VbenButton } from '@vben/common-ui';
import { MaterialSymbolsAdd } from '@vben/icons';
import { $t } from '@vben/locales';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
createCodeGenBusinessApi,
deleteCodeGenBusinessApi,
getCodeGenBusinessListApi,
importCodeGenDbTableApi,
updateCodeGenBusinessApi,
} from '../api';
import ExtraDrawer from './column.vue';
import { editSchema, importSchema, querySchema, useColumns } from './data';
const formOptions: VbenFormProps = {
collapsed: true,
showCollapseButton: true,
submitButtonOptions: {
content: $t('common.form.query'),
},
schema: querySchema,
};
const gridOptions: VxeTableGridOptions<CodeGenBusinessResult> = {
rowConfig: {
keyField: 'id',
},
checkboxConfig: {
highlight: true,
},
height: 'auto',
exportConfig: {},
printConfig: {},
toolbarConfig: {
export: true,
print: true,
refresh: true,
refreshOptions: {
code: 'query',
},
custom: true,
zoom: true,
},
columns: useColumns(onActionClick),
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCodeGenBusinessListApi({
page: page.currentPage,
size: page.pageSize,
...formValues,
});
},
},
},
};
const [Grid, gridApi] = useVbenVxeGrid({ formOptions, gridOptions });
function onActionClick({
code,
row,
}: OnActionClickParams<CodeGenBusinessResult>) {
switch (code) {
case 'delete': {
deleteCodeGenBusinessApi(row.id).then(() => {
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.table_name]),
key: 'action_process_msg',
});
onRefresh();
});
break;
}
case 'details': {
drawerApi
.setData({
pk: row.id,
datetime_mixin: row.datetime_mixin,
})
.open();
break;
}
case 'edit': {
modalApi.setData(row).open();
break;
}
}
}
function onRefresh() {
gridApi.query();
}
const [ImportForm, importFormApi] = useVbenForm({
layout: 'vertical',
showDefaultActions: false,
schema: importSchema,
});
const [ImportModal, importModalApi] = useVbenModal({
destroyOnClose: true,
async onConfirm() {
const { valid } = await importFormApi.validate();
if (valid) {
importModalApi.lock();
const data = await importFormApi.getValues<CodeGenBusinessImportParams>();
try {
await importCodeGenDbTableApi(data);
message.success($t('ui.actionMessage.operationSuccess'));
await importModalApi.close();
onRefresh();
} finally {
importModalApi.unlock();
}
}
},
});
const [Form, formApi] = useVbenForm({
wrapperClass: 'grid-cols-1 md:grid-cols-2',
showDefaultActions: false,
schema: editSchema,
});
interface formCodeGenBusinessParams extends CodeGenBusinessParams {
id?: number;
}
const formData = ref<formCodeGenBusinessParams>();
const modalTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['代码生成业务'])
: $t('ui.actionTitle.create', ['代码生成业务']);
});
const [Modal, modalApi] = useVbenModal({
class: 'w-5/12',
destroyOnClose: true,
async onConfirm() {
const { valid } = await formApi.validate();
if (valid) {
modalApi.lock();
const data = await formApi.getValues<CodeGenBusinessParams>();
try {
await (formData.value?.id
? updateCodeGenBusinessApi(formData.value.id, data)
: createCodeGenBusinessApi(data));
message.success($t('ui.actionMessage.operationSuccess'));
await modalApi.close();
onRefresh();
} finally {
modalApi.unlock();
}
}
},
onOpenChange(isOpen) {
if (isOpen) {
const data = modalApi.getData<formCodeGenBusinessParams>();
formApi.resetForm();
if (data) {
formData.value = data;
formApi.setValues(formData.value);
} else {
formData.value = undefined;
}
}
},
});
const [Drawer, drawerApi] = useVbenDrawer({
connectedComponent: ExtraDrawer,
});
</script>
<template>
<Page auto-content-height>
<Grid>
<template #toolbar-actions>
<VbenButton @click="() => modalApi.setData(null).open()">
<MaterialSymbolsAdd class="size-5" />
添加
</VbenButton>
<VbenButton
class="ml-2"
variant="outline"
@click="importModalApi.setData(null).open()"
>
<MaterialSymbolsAdd class="size-5" />
导入
</VbenButton>
</template>
</Grid>
<Modal :title="modalTitle">
<Form />
</Modal>
<ImportModal title="导入表">
<ImportForm />
</ImportModal>
<Drawer />
</Page>
</template>
@@ -0,0 +1,199 @@
<script setup lang="ts">
import type { Component } from 'vue';
import type { LanguageSupport } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { markRaw, ref } from 'vue';
import { CodeMirror, ColPage, useVbenModal } from '@vben/common-ui';
import {
BxsFile,
FlatColorIconsFolder,
MaterialIconThemePython,
} from '@vben/icons';
import { $t } from '@vben/locales';
import { useClipboard } from '@vueuse/core';
import { message } from 'antdv-next';
import { previewCodeGenApi } from '../api';
interface TreeNode {
children: TreeNode[];
title: string;
key: string;
icon: Component;
}
const treeData = ref<TreeNode[]>([]);
const codes = ref<null | Recordable<any>>(null);
const language = ref<LanguageSupport>('python');
const modalTitle = ref<string>('代码预览');
const codeContent = ref<string>('点击左侧树节点查看代码');
const previewLoading = ref(false);
const [Modal, modalApi] = useVbenModal({
destroyOnClose: true,
fullscreen: true,
fullscreenButton: false,
footer: false,
contentClass: 'p-0',
async onOpenChange(isOpen) {
if (!isOpen) {
treeData.value = [];
codes.value = null;
language.value = 'python';
modalTitle.value = '代码预览';
codeContent.value = '点击左侧树节点查看代码';
previewLoading.value = false;
return null;
}
treeData.value = [];
codes.value = null;
previewLoading.value = true;
try {
const res = await previewCodeGenApi(modalApi.getData().pk);
codes.value = res;
treeData.value = convertToTree(Object.keys(res));
} catch (error) {
console.error(error);
} finally {
previewLoading.value = false;
}
},
});
/**
* 文件路径数组转树结构
* @param paths 文件路径数组
*/
function convertToTree(paths: string[]): TreeNode[] {
const root: TreeNode[] = [];
const nodeMap = new Map<string, TreeNode>();
for (const path of paths) {
const segments = path.split('/');
let parentPath = '';
let parentChildren = root;
for (const [i, segment] of segments.entries()) {
parentPath = i === 0 ? segment : `${parentPath}/${segment}`;
let node = nodeMap.get(parentPath);
if (!node) {
node = {
icon: findIcon(parentPath),
key: parentPath,
title: segment,
children: [],
};
parentChildren.push(node);
nodeMap.set(parentPath, node);
}
parentChildren = node.children;
}
}
return root;
}
const defaultFileIcon = BxsFile;
const defaultFolderIcon = FlatColorIconsFolder;
const iconMap: Record<string, any> = {
py: MaterialIconThemePython,
};
function findIcon(path: string) {
const dotIndex = path.lastIndexOf('.');
if (dotIndex !== -1) {
const ext = path.slice(dotIndex + 1);
return markRaw(iconMap[ext] || defaultFileIcon);
}
return markRaw(defaultFolderIcon);
}
function handleSelect(selectedKeys: Array<number | string>) {
const [selectedKey] = selectedKeys;
const currentFile =
selectedKey === null || selectedKey === undefined
? ''
: String(selectedKey);
const currentCode = codes.value?.[currentFile];
if (!currentCode) return;
changeLanguageType(currentFile);
modalTitle.value = `代码预览: ${currentFile}`;
codeContent.value = currentCode;
}
const typeMap: Record<string, LanguageSupport> = {
'.py': 'python',
'.sql': 'sql',
};
function changeLanguageType(filename: string) {
const entry = Object.entries(typeMap).find(([ext]) => filename.endsWith(ext));
language.value = entry?.[1] ?? 'python';
}
const { copy } = useClipboard({ legacy: true });
function copyCodeContent(code: string) {
copy(code);
message.success($t('preferences.copyPreferencesSuccessTitle'));
}
</script>
<template>
<Modal :title="modalTitle">
<a-skeleton v-if="previewLoading" active />
<div v-else-if="codes">
<ColPage
auto-content-height
:height-offset="-35"
:resizable="false"
:left-width="30"
:right-width="70"
>
<template #left>
<div class="mr-2 h-full overflow-y-auto">
<a-tree
v-if="treeData.length > 0"
:show-line="{ showLeafIcon: false }"
:tree-data="treeData"
:virtual="false"
default-expand-all
@select="handleSelect"
>
<template #titleRender="{ title, icon }">
<div class="flex items-center gap-[8px]">
<component :is="icon" />
<span>{{ title }}</span>
</div>
</template>
</a-tree>
</div>
</template>
<CodeMirror
class="h-full overflow-y-scroll text-[13px]"
v-model="codeContent"
:language="language"
/>
<div class="fixed right-12 top-20">
<a-button @click="copyCodeContent(codeContent)">复制</a-button>
</div>
</ColPage>
</div>
<a-empty v-else description="暂无预览内容" />
</Modal>
</template>
<style lang="scss" scoped>
/** codeMirror 占满容器高度 即 calc 计算的高度 */
:deep(.cm-editor) {
height: 100%;
}
</style>
@@ -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<any>) {
return requestClient.get<ConfigResult[]>('/api/v1/sys/configs/all', {
params,
});
}
export async function updateConfigApi(params: ConfigParams[]) {
return requestClient.put('/api/v1/sys/configs', params);
}
@@ -0,0 +1,3 @@
{
"menu": "Parameter config"
}
@@ -0,0 +1,3 @@
{
"menu": "参数配置"
}
@@ -0,0 +1,6 @@
{
"name": "@fba-ui/config",
"version": "5.7.0",
"private": true,
"type": "module"
}
@@ -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;
@@ -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',
},
];
@@ -0,0 +1,134 @@
<script setup lang="ts">
import type { ConfigResult } from '../api';
import { ref } from 'vue';
import { VbenButton } from '@vben/common-ui';
import { MaterialSymbolsEdit } from '@vben/icons';
import { $t } from '@vben/locales';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import { getAllConfigApi, updateConfigApi } from '../api';
import { emailSchema } from './data';
const [Form, formApi] = useVbenForm({
showDefaultActions: false,
schema: emailSchema,
commonConfig: {
controlClass: 'w-full max-w-80',
disabled: true,
labelClass: 'justify-start ml-2',
labelWidth: 120,
hideRequiredMark: true,
},
});
const editButtonShow = ref<boolean>(true);
const loading = ref<boolean>(false);
const saveLoading = ref<boolean>(false);
const emailData = ref<ConfigResult[]>([]);
const fetchConfigList = async () => {
loading.value = true;
try {
emailData.value = await getAllConfigApi({ type: 'EMAIL' });
emailData.value.forEach((config: any) => {
formApi.setState((prev: any) => {
return {
schema: prev.schema?.map((item: any) => {
if (item.fieldName === config.key) {
return {
...item,
label: config.name,
};
}
return item;
}),
};
});
formApi.setValues({ [config.key]: config.value });
});
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
};
const saveEmailConfig = async () => {
const { valid } = await formApi.validate();
if (valid) {
const data: Record<string, any> = await formApi.getValues();
emailData.value.forEach((config: any) => {
if (Object.prototype.hasOwnProperty.call(data, config.key)) {
config.value = data[config.key];
}
});
saveLoading.value = true;
try {
await updateConfigApi(emailData.value);
message.success($t('ui.actionMessage.operationSuccess'));
editButtonShow.value = true;
formApi.setState({ commonConfig: { disabled: true } });
await fetchConfigList();
} catch (error) {
console.error(error);
} finally {
saveLoading.value = false;
}
}
};
defineExpose({
fetchConfigList,
});
</script>
<template>
<a-spin :spinning="loading">
<div>
<Form />
<VbenButton
v-show="editButtonShow"
class="ml-1.5 mt-3"
@click="
() => {
editButtonShow = false;
formApi.setState({ commonConfig: { disabled: false } });
}
"
>
<MaterialSymbolsEdit class="mr-1" />
修改
</VbenButton>
<VbenButton
v-show="!editButtonShow"
class="ml-1.5 mt-3"
:loading="saveLoading"
@click="saveEmailConfig"
>
<MaterialSymbolsEdit class="mr-1" />
保存
</VbenButton>
<VbenButton
v-show="!editButtonShow"
class="ml-3 mt-3"
:disabled="saveLoading"
variant="outline"
@click="
() => {
editButtonShow = true;
formApi.setState({ commonConfig: { disabled: true } });
fetchConfigList();
}
"
>
<MaterialSymbolsEdit class="mr-1" />
取消
</VbenButton>
</div>
</a-spin>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { h, nextTick, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import Email from './email.vue';
import Login from './login.vue';
import UserSecurity from './user-security.vue';
const activeKey = ref('0');
const userSecurityRef = ref();
const loginRef = ref();
const emailRef = ref();
const tabItems = [
{
key: '0',
label: '安全配置',
icon: () => h('span', { class: 'icon-[carbon--security] -mb-1 size-5' }),
},
{
key: '1',
label: '登录配置',
icon: () =>
h('span', { class: 'icon-[majesticons--lock-line] -mb-1 size-5' }),
},
{
key: '2',
label: '邮件配置',
icon: () => h('span', { class: 'icon-[ic--outline-email] -mb-1 size-5' }),
},
];
watch(activeKey, async (newValue) => {
if (newValue === '0') {
await nextTick();
if (userSecurityRef.value) {
await userSecurityRef.value.fetchConfigList();
}
}
if (newValue === '1') {
await nextTick();
if (loginRef.value) {
await loginRef.value.fetchConfigList();
}
}
if (newValue === '2') {
await nextTick();
if (emailRef.value) {
await emailRef.value.fetchConfigList();
}
}
});
onMounted(async () => {
await nextTick();
if (userSecurityRef.value) {
await userSecurityRef.value.fetchConfigList();
}
});
</script>
<template>
<Page auto-content-height>
<a-card
class="h-full overflow-y-auto rounded-[var(--radius)]"
variant="borderless"
>
<a-tabs
class="h-full"
v-model:active-key="activeKey"
tab-placement="start"
animated
:tab-bar-style="{ width: '16%' }"
:items="tabItems"
>
<template #contentRender="{ item }">
<UserSecurity v-if="item.key === '0'" ref="userSecurityRef" />
<Login v-else-if="item.key === '1'" ref="loginRef" />
<Email v-else-if="item.key === '2'" ref="emailRef" />
</template>
</a-tabs>
</a-card>
</Page>
</template>
<style lang="scss" scoped>
:deep(.ant-card-body) {
height: 100%;
min-height: 100%;
}
</style>
@@ -0,0 +1,135 @@
<script setup lang="ts">
import type { ConfigResult } from '../api';
import { ref } from 'vue';
import { VbenButton } from '@vben/common-ui';
import { MaterialSymbolsEdit } from '@vben/icons';
import { $t } from '@vben/locales';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import { getAllConfigApi, updateConfigApi } from '../api';
import { loginSchema } from './data';
const [Form, formApi] = useVbenForm({
showDefaultActions: false,
schema: loginSchema,
commonConfig: {
controlClass: 'w-full max-w-80',
disabled: true,
labelClass: 'justify-start ml-2 self-start',
labelWidth: 120,
wrapperClass: 'flex-col items-start',
hideRequiredMark: true,
},
});
const editButtonShow = ref<boolean>(true);
const loading = ref<boolean>(false);
const saveLoading = ref<boolean>(false);
const loginData = ref<ConfigResult[]>([]);
const fetchConfigList = async () => {
loading.value = true;
try {
loginData.value = await getAllConfigApi({ type: 'LOGIN' });
loginData.value.forEach((config: any) => {
formApi.setState((prev: any) => {
return {
schema: prev.schema?.map((item: any) => {
if (item.fieldName === config.key) {
return {
...item,
label: config.name,
};
}
return item;
}),
};
});
formApi.setValues({ [config.key]: config.value });
});
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
};
const saveLoginConfig = async () => {
const { valid } = await formApi.validate();
if (valid) {
const data: Record<string, any> = await formApi.getValues();
loginData.value.forEach((config: any) => {
if (Object.prototype.hasOwnProperty.call(data, config.key)) {
config.value = data[config.key];
}
});
saveLoading.value = true;
try {
await updateConfigApi(loginData.value);
message.success($t('ui.actionMessage.operationSuccess'));
editButtonShow.value = true;
formApi.setState({ commonConfig: { disabled: true } });
await fetchConfigList();
} catch (error) {
console.error(error);
} finally {
saveLoading.value = false;
}
}
};
defineExpose({
fetchConfigList,
});
</script>
<template>
<a-spin :spinning="loading">
<div>
<Form />
<VbenButton
v-show="editButtonShow"
class="ml-1.5 mt-3"
@click="
() => {
editButtonShow = false;
formApi.setState({ commonConfig: { disabled: false } });
}
"
>
<MaterialSymbolsEdit class="mr-1" />
修改
</VbenButton>
<VbenButton
v-show="!editButtonShow"
class="ml-1.5 mt-3"
:loading="saveLoading"
@click="saveLoginConfig"
>
<MaterialSymbolsEdit class="mr-1" />
保存
</VbenButton>
<VbenButton
v-show="!editButtonShow"
class="ml-3 mt-3"
:disabled="saveLoading"
variant="outline"
@click="
() => {
editButtonShow = true;
formApi.setState({ commonConfig: { disabled: true } });
fetchConfigList();
}
"
>
<MaterialSymbolsEdit class="mr-1" />
取消
</VbenButton>
</div>
</a-spin>
</template>
@@ -0,0 +1,134 @@
<script setup lang="ts">
import type { ConfigResult } from '../api';
import { ref } from 'vue';
import { VbenButton } from '@vben/common-ui';
import { MaterialSymbolsEdit } from '@vben/icons';
import { $t } from '@vben/locales';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import { getAllConfigApi, updateConfigApi } from '../api';
import { userSecuritySchema } from './data';
const [Form, formApi] = useVbenForm({
showDefaultActions: false,
schema: userSecuritySchema,
commonConfig: {
controlClass: 'w-full max-w-80',
disabled: true,
labelClass: 'justify-start ml-2 self-start',
labelWidth: 180,
hideRequiredMark: true,
},
});
const editButtonShow = ref<boolean>(true);
const loading = ref<boolean>(false);
const saveLoading = ref<boolean>(false);
const userSecurityData = ref<ConfigResult[]>([]);
const fetchConfigList = async () => {
loading.value = true;
try {
userSecurityData.value = await getAllConfigApi({ type: 'USER_SECURITY' });
userSecurityData.value.forEach((config: any) => {
formApi.setState((prev: any) => {
return {
schema: prev.schema?.map((item: any) => {
if (item.fieldName === config.key) {
return {
...item,
label: config.name,
};
}
return item;
}),
};
});
formApi.setValues({ [config.key]: config.value });
});
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
};
const saveUserSecurityConfig = async () => {
const { valid } = await formApi.validate();
if (valid) {
const data: Record<string, any> = await formApi.getValues();
userSecurityData.value.forEach((config: any) => {
if (Object.prototype.hasOwnProperty.call(data, config.key)) {
config.value = data[config.key];
}
});
saveLoading.value = true;
try {
await updateConfigApi(userSecurityData.value);
message.success($t('ui.actionMessage.operationSuccess'));
editButtonShow.value = true;
formApi.setState({ commonConfig: { disabled: true } });
await fetchConfigList();
} catch (error) {
console.error(error);
} finally {
saveLoading.value = false;
}
}
};
defineExpose({
fetchConfigList,
});
</script>
<template>
<a-spin :spinning="loading">
<div>
<Form />
<VbenButton
v-show="editButtonShow"
class="ml-1.5 mt-3"
@click="
() => {
editButtonShow = false;
formApi.setState({ commonConfig: { disabled: false } });
}
"
>
<MaterialSymbolsEdit class="mr-1" />
修改
</VbenButton>
<VbenButton
v-show="!editButtonShow"
class="ml-1.5 mt-3"
:loading="saveLoading"
@click="saveUserSecurityConfig"
>
<MaterialSymbolsEdit class="mr-1" />
保存
</VbenButton>
<VbenButton
v-show="!editButtonShow"
class="ml-3 mt-3"
:disabled="saveLoading"
variant="outline"
@click="
() => {
editButtonShow = true;
formApi.setState({ commonConfig: { disabled: true } });
fetchConfigList();
}
"
>
<MaterialSymbolsEdit class="mr-1" />
取消
</VbenButton>
</div>
</a-spin>
</template>
@@ -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<DictTypeResult[]>(
'/api/v1/sys/dict-types/all',
);
}
export async function getDictTypeListApi(params: DictTypeParams) {
return await requestClient.get<PaginationResult<DictTypeResult>>(
'/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<DictDataResult[]>(
`/api/v1/sys/dict-datas/type-codes/${code}`,
);
}
export async function getDictDataListApi(params: DictDataParams) {
return await requestClient.get<PaginationResult<DictDataResult>>(
'/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 },
});
}
@@ -0,0 +1,3 @@
{
"menu": "Dict"
}
@@ -0,0 +1,3 @@
{
"menu": "字典管理"
}
@@ -0,0 +1,6 @@
{
"name": "@fba-ui/dict",
"version": "5.7.0",
"private": true,
"type": "module"
}
@@ -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;
@@ -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<DictTypeResult>,
): 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<DictDataResult>,
): 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: '备注',
},
];
@@ -0,0 +1,188 @@
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { CreateDictDataParams, DictDataResult } from '../api';
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import { computed, ref } from 'vue';
import { useVbenModal, VbenButton } from '@vben/common-ui';
import { EmptyIcon, MaterialSymbolsAdd } from '@vben/icons';
import { $t } from '@vben/locales';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
createDictDataApi,
deleteDictDataApi,
getDictDataListApi,
updateDictDataApi,
} from '../api';
import {
dictDataSchema,
queryDictDataSchema,
useDictDataColumns,
} from './data';
import { emitter } from './mitt';
const formOptions: VbenFormProps = {
collapsed: true,
showCollapseButton: true,
submitButtonOptions: {
content: $t('common.form.query'),
},
schema: queryDictDataSchema,
};
const gridOptions: VxeTableGridOptions<DictDataResult> = {
rowConfig: {
keyField: 'id',
},
checkboxConfig: {
highlight: true,
},
height: 'auto',
exportConfig: {},
printConfig: {},
toolbarConfig: {
export: true,
print: true,
refresh: true,
refreshOptions: {
code: 'query',
},
custom: true,
zoom: true,
},
columns: useDictDataColumns(onActionClick),
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
if (!dictTypeId.value) {
return [];
}
const params = {
page: page.currentPage,
size: page.pageSize,
type_id: dictTypeId.value,
...formValues,
};
return await getDictDataListApi(params);
},
},
},
};
const dictTypeId = ref<number>(0);
const [Grid, gridApi] = useVbenVxeGrid({ formOptions, gridOptions });
function onActionClick({ code, row }: OnActionClickParams<DictDataResult>) {
switch (code) {
case 'delete': {
deleteDictDataApi([row.id]).then(() => {
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.label]),
key: 'action_process_msg',
});
onRefresh();
});
break;
}
case 'edit': {
modalApi.setData(row).open();
break;
}
}
}
function onRefresh() {
gridApi.query();
}
const [Form, formApi] = useVbenForm({
layout: 'vertical',
showDefaultActions: false,
schema: dictDataSchema,
});
interface formDictDataParams extends CreateDictDataParams {
id?: number;
}
const formData = ref<formDictDataParams>();
const modalTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['字典数据'])
: $t('ui.actionTitle.create', ['字典数据']);
});
const [Modal, modalApi] = useVbenModal({
destroyOnClose: true,
async onConfirm() {
const { valid } = await formApi.validate();
if (valid) {
modalApi.lock();
const data = await formApi.getValues<CreateDictDataParams>();
try {
await (formData.value?.id
? updateDictDataApi(formData.value?.id, data)
: createDictDataApi(data));
message.success($t('ui.actionMessage.operationSuccess'));
await modalApi.close();
onRefresh();
} finally {
modalApi.unlock();
}
}
},
onOpenChange(isOpen) {
if (isOpen) {
const data = modalApi.getData<formDictDataParams>();
formApi.resetForm();
if (data) {
formData.value = data;
formApi.setValues(data);
}
}
},
});
emitter.on('rowClick', async (value) => {
dictTypeId.value = value;
onRefresh();
});
</script>
<template>
<Grid table-title="字典数据">
<template #toolbar-tools>
<VbenButton
:disabled="dictTypeId === 0"
@click="() => modalApi.setData({ type_id: dictTypeId }).open()"
>
<MaterialSymbolsAdd class="size-5" />
新增
</VbenButton>
</template>
<template #empty>
<slot name="empty">
<EmptyIcon class="mx-auto" />
<div class="mt-2">点击字典类型行以获取字典数据</div>
</slot>
</template>
</Grid>
<Modal :title="modalTitle">
<Form />
</Modal>
</template>
@@ -0,0 +1,187 @@
<script lang="ts" setup>
import type { VbenFormProps } from '@vben/common-ui';
import type {
CreateDictTypeParams,
DictDataResult,
DictTypeResult,
} from '../api';
import type {
OnActionClickParams,
VxeGridListeners,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import { computed, ref } from 'vue';
import { useVbenModal, VbenButton } from '@vben/common-ui';
import { MaterialSymbolsAdd } from '@vben/icons';
import { $t } from '@vben/locales';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
createDictTypeApi,
deleteDictTypeApi,
getDictTypeListApi,
updateDictTypeApi,
} from '../api';
import {
dictTypeSchema,
queryDictTypeSchema,
useDictTypeColumns,
} from './data';
import { emitter } from './mitt';
const formOptions: VbenFormProps = {
wrapperClass: 'md:grid-cols-2',
collapsed: true,
showCollapseButton: true,
submitButtonOptions: {
content: $t('common.form.query'),
},
schema: queryDictTypeSchema,
};
const gridOptions: VxeTableGridOptions<DictTypeResult> = {
rowConfig: {
keyField: 'id',
isCurrent: true,
isHover: true,
},
checkboxConfig: {
highlight: true,
},
height: 'auto',
exportConfig: {},
printConfig: {},
toolbarConfig: {
export: true,
print: true,
refresh: true,
refreshOptions: {
code: 'query',
},
custom: true,
zoom: true,
},
columns: useDictTypeColumns(onActionClick),
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDictTypeListApi({
page: page.currentPage,
size: page.pageSize,
...formValues,
});
},
},
},
};
const lastDictTypeId = ref<number>(0);
const gridEvents: VxeGridListeners<DictDataResult> = {
cellClick: ({ row }) => {
if (lastDictTypeId.value === row.id) {
return;
}
emitter.emit('rowClick', row.id);
lastDictTypeId.value = row.id;
},
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents,
});
function onActionClick({ code, row }: OnActionClickParams<DictTypeResult>) {
switch (code) {
case 'delete': {
deleteDictTypeApi([row.id]).then(() => {
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_process_msg',
});
onRefresh();
});
break;
}
case 'edit': {
modalApi.setData(row).open();
break;
}
}
}
function onRefresh() {
gridApi.query();
}
const [Form, formApi] = useVbenForm({
layout: 'vertical',
showDefaultActions: false,
schema: dictTypeSchema,
});
interface formDictTypeParams extends CreateDictTypeParams {
id?: number;
}
const formData = ref<formDictTypeParams>();
const modalTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['字典类型'])
: $t('ui.actionTitle.create', ['字典类型']);
});
const [Modal, modalApi] = useVbenModal({
destroyOnClose: true,
async onConfirm() {
const { valid } = await formApi.validate();
if (valid) {
modalApi.lock();
const data = await formApi.getValues<CreateDictTypeParams>();
try {
await (formData.value?.id
? updateDictTypeApi(formData.value?.id, data)
: createDictTypeApi(data));
message.success($t('ui.actionMessage.operationSuccess'));
await modalApi.close();
onRefresh();
} finally {
modalApi.unlock();
}
}
},
onOpenChange(isOpen) {
if (isOpen) {
const data = modalApi.getData<formDictTypeParams>();
formApi.resetForm();
if (data) {
formData.value = data;
formApi.setValues(data);
}
}
},
});
</script>
<template>
<Grid table-title="字典类型">
<template #toolbar-tools>
<VbenButton @click="() => modalApi.setData(null).open()">
<MaterialSymbolsAdd class="size-5" />
新增
</VbenButton>
</template>
</Grid>
<Modal :title="modalTitle">
<Form />
</Modal>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { ColPage } from '@vben/common-ui';
import DictData from './dict-data.vue';
import DictType from './dict-type.vue';
</script>
<template>
<ColPage
auto-content-height
:resizable="false"
:left-width="40"
:right-width="60"
>
<template #left>
<div class="mr-2 h-full">
<DictType />
</div>
</template>
<DictData />
</ColPage>
</template>
@@ -0,0 +1,7 @@
import mitt from 'mitt';
type Events = {
rowClick: number;
};
export const emitter = mitt<Events>();
@@ -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);
}
@@ -0,0 +1,6 @@
{
"name": "@fba-ui/email",
"version": "5.7.0",
"private": true,
"type": "module"
}
@@ -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<PaginationResult<SysNoticeResult>>(
'/api/v1/sys/notices',
{ params },
);
}
export async function getSysNoticeApi(pk: number) {
return requestClient.get<SysNoticeResult>(`/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 } });
}
@@ -0,0 +1,3 @@
{
"menu": "Notice"
}
@@ -0,0 +1,3 @@
{
"menu": "通知公告"
}
@@ -0,0 +1,6 @@
{
"name": "@fba-ui/notice",
"version": "5.7.0",
"private": true,
"type": "module"
}
@@ -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;
@@ -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<SysNoticeResult>,
): 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',
},
];
@@ -0,0 +1,199 @@
<script lang="ts" setup>
import type { VbenFormProps } from '@vben/common-ui';
import type {
CreateSysNoticeParams,
SysNoticeResult,
UpdateSysNoticeParams,
} from '../api';
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import { computed, ref } from 'vue';
import {
MarkdownPreviewer,
Page,
useVbenModal,
VbenButton,
} from '@vben/common-ui';
import { MaterialSymbolsAdd } from '@vben/icons';
import { $t } from '@vben/locales';
import { message } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
createSysNoticeApi,
deleteSysNoticeApi,
getNoticeListApi,
updateSysNoticeApi,
} from '../api';
import { querySchema, schema, useColumns } from './data';
const formOptions: VbenFormProps = {
collapsed: true,
showCollapseButton: true,
submitButtonOptions: {
content: $t('common.form.query'),
},
schema: querySchema,
};
const gridOptions: VxeTableGridOptions<SysNoticeResult> = {
rowConfig: {
keyField: 'id',
},
checkboxConfig: {
highlight: true,
},
height: 'auto',
exportConfig: {},
printConfig: {},
toolbarConfig: {
export: true,
print: true,
refresh: true,
refreshOptions: {
code: 'query',
},
custom: true,
zoom: true,
},
columns: useColumns(onActionClick),
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getNoticeListApi({
page: page.currentPage,
size: page.pageSize,
...formValues,
});
},
},
},
};
const [Grid, gridApi] = useVbenVxeGrid({ formOptions, gridOptions });
function onActionClick({ code, row }: OnActionClickParams<SysNoticeResult>) {
switch (code) {
case 'delete': {
deleteSysNoticeApi([row.id]).then(() => {
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.title]),
key: 'action_process_msg',
});
onRefresh();
});
break;
}
case 'edit': {
modalApi.setData(row).open();
break;
}
case 'preview': {
previewModalApi.setData(row).open();
break;
}
}
}
function onRefresh() {
gridApi.query();
}
const [Form, formApi] = useVbenForm({
wrapperClass: 'md:grid-cols-2',
layout: 'vertical',
showDefaultActions: false,
schema,
});
interface formSysNoticeParams extends UpdateSysNoticeParams {
id?: number;
}
const formData = ref<formSysNoticeParams>();
const modalTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['角色'])
: $t('ui.actionTitle.create', ['角色']);
});
const [Modal, modalApi] = useVbenModal({
destroyOnClose: true,
class: 'w-5/12',
async onConfirm() {
const { valid } = await formApi.validate();
if (valid) {
modalApi.lock();
const data = await formApi.getValues<CreateSysNoticeParams>();
try {
await (formData.value?.id
? updateSysNoticeApi(formData.value?.id, data)
: createSysNoticeApi(data));
message.success($t('ui.actionMessage.operationSuccess'));
await modalApi.close();
onRefresh();
} finally {
modalApi.unlock();
}
}
},
onOpenChange(isOpen) {
if (isOpen) {
const data = modalApi.getData<formSysNoticeParams>();
formApi.resetForm();
if (data) {
formData.value = data;
formApi.setValues(data);
}
}
},
});
const preViewTitle = ref<string>('');
const preViewContent = ref<string>('');
const [PreviewModal, previewModalApi] = useVbenModal({
title: '通知公告预览',
class: 'w-5/12',
footer: false,
onOpenChange: (isOpen) => {
if (isOpen) {
const data = previewModalApi.getData<formSysNoticeParams>();
if (data) {
preViewTitle.value = data.title;
preViewContent.value = data.content;
}
}
},
});
</script>
<template>
<Page auto-content-height>
<Grid>
<template #toolbar-actions>
<VbenButton @click="() => modalApi.setData(null).open()">
<MaterialSymbolsAdd class="size-5" />
新增通知公告
</VbenButton>
</template>
</Grid>
<Modal :title="modalTitle">
<Form />
</Modal>
<PreviewModal>
<div class="mb-3 text-center text-3xl">{{ preViewTitle }}</div>
<MarkdownPreviewer class="p-3" :value="preViewContent" />
</PreviewModal>
</Page>
</template>
@@ -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<string>('/api/v1/oauth2/github');
}
export async function getOAuth2Google() {
return requestClient.get<string>('/api/v1/oauth2/google');
}
export async function getOAuth2Bindings() {
return requestClient.get<string[]>('/api/v1/oauth2/me/bindings');
}
export async function getOAuth2BindingAuthUrl(params: OAuth2BindingParams) {
return requestClient.get<string>('/api/v1/oauth2/me/binding', { params });
}
export async function deleteOAuth2Binding(params: OAuth2BindingParams) {
return requestClient.delete('/api/v1/oauth2/me/unbinding', { params });
}
@@ -0,0 +1,6 @@
{
"name": "@fba-ui/oauth2",
"version": "5.7.0",
"private": true,
"type": "module"
}
@@ -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;
@@ -0,0 +1,7 @@
<script lang="ts" setup></script>
<template>
<div class="grid min-h-screen place-items-center">
<a-spin size="large" tip="OAuthing..." />
</div>
</template>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { ref } from 'vue';
import { SvgGithubIcon, SvgGoogleIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { getOAuth2Github, getOAuth2Google } from '../api';
defineOptions({ name: 'OAuth2Login' });
interface OAuth2Source {
source: 'Github' | 'Google';
}
const oauth2Loading = ref<null | OAuth2Source['source']>(null);
const OAuth2 = async (oa: OAuth2Source) => {
if (oauth2Loading.value) {
return;
}
oauth2Loading.value = oa.source;
try {
switch (oa.source) {
case 'Github': {
window.location.href = await getOAuth2Github();
break;
}
case 'Google': {
window.location.href = await getOAuth2Google();
break;
}
// No default
}
} catch (error) {
console.error(error);
oauth2Loading.value = null;
}
};
</script>
<template>
<div class="w-full sm:mx-auto md:max-w-md">
<div class="mt-4 flex items-center justify-between">
<span class="w-[35%] border-b border-input dark:border-gray-600"></span>
<span class="text-center text-xs text-muted-foreground uppercase">
{{ $t('authentication.thirdPartyLogin') }}
</span>
<span class="w-[35%] border-b border-input dark:border-gray-600"></span>
</div>
<div class="mt-4 flex flex-wrap justify-center gap-3">
<a-tooltip :title="$t('authentication.githubLogin')" placement="top">
<a-button
type="text"
shape="circle"
:loading="oauth2Loading === 'Github'"
:disabled="!!oauth2Loading"
@click="OAuth2({ source: 'Github' })"
>
<SvgGithubIcon v-if="oauth2Loading !== 'Github'" class="size-5" />
</a-button>
</a-tooltip>
<a-tooltip :title="$t('authentication.googleLogin')" placement="top">
<a-button
type="text"
shape="circle"
:loading="oauth2Loading === 'Google'"
:disabled="!!oauth2Loading"
@click="OAuth2({ source: 'Google' })"
>
<SvgGoogleIcon v-if="oauth2Loading !== 'Google'" class="size-5" />
</a-button>
</a-tooltip>
</div>
</div>
</template>
@@ -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,
},
});
@@ -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 };
@@ -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<string>();
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 };

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