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
@@ -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 };
@@ -0,0 +1,37 @@
import {
createRouter,
createWebHashHistory,
createWebHistory,
} from 'vue-router';
import { resetStaticRoutes } from '@vben/utils';
import { createRouterGuard } from './guard';
import { routes } from './routes';
/**
* @zh_CN 创建vue-router实例
*/
const router = createRouter({
history:
import.meta.env.VITE_ROUTER_HISTORY === 'hash'
? createWebHashHistory(import.meta.env.VITE_BASE)
: createWebHistory(import.meta.env.VITE_BASE),
// 应该添加到路由的初始路由列表。
routes,
scrollBehavior: (to, _from, savedPosition) => {
if (savedPosition) {
return savedPosition;
}
return to.hash ? { behavior: 'smooth', el: to.hash } : { left: 0, top: 0 };
},
// 是否应该禁止尾部斜杠。
// strict: true,
});
const resetRoutes = () => resetStaticRoutes(router, routes);
// 创建路由守卫
createRouterGuard(router);
export { resetRoutes, router };
@@ -0,0 +1,97 @@
import type { RouteRecordRaw } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
import { $t } from '#/locales';
const BasicLayout = () => import('#/layouts/basic.vue');
const AuthPageLayout = () => import('#/layouts/auth.vue');
/** 全局404页面 */
const fallbackNotFoundRoute: RouteRecordRaw = {
component: () => import('#/views/_core/fallback/not-found.vue'),
meta: {
hideInBreadcrumb: true,
hideInMenu: true,
hideInTab: true,
title: '404',
},
name: 'FallbackNotFound',
path: '/:path(.*)*',
};
/** 基本路由,这些路由是必须存在的 */
const coreRoutes: RouteRecordRaw[] = [
/**
* 根路由
* 使用基础布局,作为所有页面的父级容器,子级就不必配置BasicLayout。
* 此路由必须存在,且不应修改
*/
{
component: BasicLayout,
meta: {
hideInBreadcrumb: true,
title: 'Root',
},
name: 'Root',
path: '/',
redirect: preferences.app.defaultHomePath,
children: [],
},
{
component: AuthPageLayout,
meta: {
hideInTab: true,
title: 'Authentication',
},
name: 'Authentication',
path: '/auth',
redirect: LOGIN_PATH,
children: [
{
name: 'Login',
path: 'login',
component: () => import('#/views/_core/authentication/login.vue'),
meta: {
title: $t('page.auth.login'),
},
},
{
name: 'CodeLogin',
path: 'code-login',
component: () => import('#/views/_core/authentication/code-login.vue'),
meta: {
title: $t('page.auth.codeLogin'),
},
},
{
name: 'QrCodeLogin',
path: 'qrcode-login',
component: () =>
import('#/views/_core/authentication/qrcode-login.vue'),
meta: {
title: $t('page.auth.qrcodeLogin'),
},
},
{
name: 'ForgetPassword',
path: 'forget-password',
component: () =>
import('#/views/_core/authentication/forget-password.vue'),
meta: {
title: $t('page.auth.forgetPassword'),
},
},
{
name: 'Register',
path: 'register',
component: () => import('#/views/_core/authentication/register.vue'),
meta: {
title: $t('page.auth.register'),
},
},
],
},
];
export { coreRoutes, fallbackNotFoundRoute };
@@ -0,0 +1,55 @@
import type { RouteRecordRaw } from 'vue-router';
import { mergeRouteModules, traverseTreeValues } from '@vben/utils';
import { coreRoutes, fallbackNotFoundRoute } from './core';
const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', {
eager: true,
});
const pluginRouteFiles = import.meta.glob('../../plugins/**/routes/*.ts', {
eager: true,
});
// 有需要可以自行打开注释,并创建文件夹
// const externalRouteFiles = import.meta.glob('./external/**/*.ts', { eager: true });
// const staticRouteFiles = import.meta.glob('./static/**/*.ts', { eager: true });
/** 动态路由 */
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
/** 插件路由 */
const pluginRoutes: RouteRecordRaw[] = mergeRouteModules(pluginRouteFiles);
/** 外部路由列表,访问这些页面可以不需要Layout,可能用于内嵌在别的系统(不会显示在菜单中) */
// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
const staticRoutes: RouteRecordRaw[] = [];
const externalRoutes: RouteRecordRaw[] = [];
/** 路由列表,由基本路由、外部路由和404兜底路由组成
* 无需走权限验证(会一直显示在菜单中) */
const routes: RouteRecordRaw[] = [
...coreRoutes,
...externalRoutes,
fallbackNotFoundRoute,
];
/** 基本路由列表,这些路由不需要进入权限拦截 */
const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
/** 有权限校验的路由列表,包含动态路由和静态路由 */
const accessRoutes = [...dynamicRoutes, ...pluginRoutes, ...staticRoutes];
const componentKeys: string[] = Object.keys({
...import.meta.glob('../../views/**/*.vue'),
...import.meta.glob('../../plugins/**/*.vue'),
})
.filter((item) => !item.includes('/modules/'))
.map((v) => {
const path = v.replace('../../views/', '/').replace('../../', '/');
return path.endsWith('.vue') ? path.slice(0, -4) : path;
});
export { accessRoutes, componentKeys, coreRouteNames, routes };
@@ -0,0 +1,38 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:layout-dashboard',
order: -1,
title: $t('page.dashboard.title'),
},
name: 'Dashboard',
path: '/dashboard',
children: [
{
name: 'Analytics',
path: '/analytics',
component: () => import('#/views/dashboard/analytics/index.vue'),
meta: {
affixTab: true,
icon: 'lucide:area-chart',
title: $t('page.dashboard.analytics'),
},
},
{
name: 'Workspace',
path: '/workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',
title: $t('page.dashboard.workspace'),
},
},
],
},
];
export default routes;
@@ -0,0 +1,28 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'ic:baseline-view-in-ar',
keepAlive: true,
order: 1000,
title: $t('demos.title'),
},
name: 'Demos',
path: '/demos',
children: [
{
meta: {
title: $t('demos.antd'),
},
name: 'AntDesignDemos',
path: '/demos/ant-design-next',
component: () => import('#/views/demos/antd/index.vue'),
},
],
},
];
export default routes;
@@ -0,0 +1,37 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
name: 'Log',
path: '/log',
meta: {
title: $t('page.menu.log'),
icon: 'carbon:cloud-logging',
order: 3,
},
children: [
{
name: 'LoginLog',
path: '/log/login',
component: () => import('#/views/log/login/index.vue'),
meta: {
title: $t('page.menu.login'),
icon: 'mdi:login',
},
},
{
name: 'OperaLog',
path: '/log/opera',
component: () => import('#/views/log/opera/index.vue'),
meta: {
title: $t('page.menu.opera'),
icon: 'carbon:operations-record',
},
},
],
},
];
export default routes;
@@ -0,0 +1,46 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
name: 'Monitor',
path: '/monitor',
meta: {
title: $t('page.menu.monitor'),
icon: 'mdi:monitor-eye',
order: 4,
},
children: [
{
name: 'Online',
path: '/monitor/online',
component: () => import('#/views/monitor/online/index.vue'),
meta: {
title: $t('page.menu.online'),
icon: 'wpf:online',
},
},
{
name: 'Redis',
path: '/monitor/redis',
component: () => import('#/views/monitor/redis/index.vue'),
meta: {
title: $t('page.menu.redis'),
icon: 'devicon:redis',
},
},
{
name: 'Server',
path: '/monitor/server',
component: () => import('#/views/monitor/server/index.vue'),
meta: {
title: $t('page.menu.server'),
icon: 'mdi:server-outline',
},
},
],
},
];
export default routes;
@@ -0,0 +1,36 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
name: 'Scheduler',
path: '/scheduler',
meta: {
title: $t('page.menu.scheduler'),
icon: 'ix:scheduler',
},
children: [
{
name: 'SchedulerManage',
path: '/scheduler/manage',
component: () => import('#/views/scheduler/manage/index.vue'),
meta: {
title: $t('page.menu.schedulerManage'),
icon: 'ix:scheduler',
},
},
{
name: 'SchedulerRecord',
path: '/scheduler/record',
component: () => import('#/views/scheduler/record/index.vue'),
meta: {
title: $t('page.menu.schedulerRecord'),
icon: 'ix:scheduler',
},
},
],
},
];
export default routes;
@@ -0,0 +1,94 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
name: 'System',
path: '/system',
meta: {
title: $t('page.menu.system'),
icon: 'grommet-icons:system',
order: 1,
},
children: [
{
name: 'SysDept',
path: '/system/dept',
component: () => import('#/views/system/dept/index.vue'),
meta: {
title: $t('page.menu.sysDept'),
icon: 'mingcute:department-line',
},
},
{
name: 'SysUser',
path: '/system/user',
component: () => import('#/views/system/user/index.vue'),
meta: {
title: $t('page.menu.sysUser'),
icon: 'ant-design:user-outlined',
},
},
{
name: 'SysRole',
path: '/system/role',
component: () => import('#/views/system/role/index.vue'),
meta: {
title: $t('page.menu.sysRole'),
icon: 'carbon:user-role',
},
},
{
name: 'SysMenu',
path: '/system/menu',
component: () => import('#/views/system/menu/index.vue'),
meta: {
title: $t('page.menu.sysMenu'),
icon: 'material-symbols:menu',
},
},
{
name: 'SysDataPermission',
path: '/system/data-permission',
meta: {
title: $t('page.menu.sysDataPermission'),
icon: 'icon-park-outline:permissions',
},
children: [
{
name: 'SysDataScope',
path: '/system/data-scope',
component: () =>
import('#/views/system/data-permission/scope/index.vue'),
meta: {
title: $t('page.menu.sysDataScope'),
icon: 'cuida:scope-outline',
},
},
{
name: 'SysDataRule',
path: '/system/data-rule',
component: () =>
import('#/views/system/data-permission/rule/index.vue'),
meta: {
title: $t('page.menu.sysDataRule'),
icon: 'material-symbols:rule',
},
},
],
},
{
name: 'SysPlugin',
path: '/system/plugin',
component: () => import('#/views/system/plugin/index.vue'),
meta: {
title: $t('page.menu.sysPlugin'),
icon: 'clarity:plugin-line',
},
},
],
},
];
export default routes;
@@ -0,0 +1,72 @@
import type { RouteRecordRaw } from 'vue-router';
import { IFrameView } from '#/layouts';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
badgeType: 'dot',
icon: 'https://wu-clan.github.io/picx-images-hosting/logo/fba.png',
order: 9998,
title: '项目',
},
name: 'VbenProject',
path: '/fba',
children: [
{
name: 'Document',
path: '/fba/document',
component: IFrameView,
meta: {
icon: 'lucide:book-open-text',
link: 'https://fastapi-practices.github.io/fastapi_best_architecture_docs',
title: $t('demos.vben.document'),
},
},
{
name: 'Github',
path: '/fba/github',
component: IFrameView,
meta: {
icon: 'mdi:github',
link: 'https://github.com/fastapi-practices/fastapi_best_architecture',
title: 'Github',
},
},
{
name: 'Apifox',
path: '/fba/apifox',
component: IFrameView,
meta: {
icon: 'simple-icons:apifox',
iframeSrc:
'https://apifox.com/apidoc/shared-28a93f02-730b-4f33-bb5e-4dad92058cc0',
title: 'Apifox',
},
},
],
},
{
name: 'VbenAbout',
path: '/about',
component: () => import('#/views/_core/about/index.vue'),
meta: {
icon: 'lucide:copyright',
title: $t('demos.vben.about'),
order: 9999,
},
},
{
name: 'Profile',
path: '/profile',
component: () => import('#/views/_core/profile/index.vue'),
meta: {
icon: 'lucide:user',
hideInMenu: true,
title: $t('page.auth.profile'),
},
},
];
export default routes;
@@ -0,0 +1,161 @@
import type { Recordable } from '@vben/types';
import type { CaptchaResult, LoginParams, MyUserInfo } from '#/api';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
import { notification } from 'antdv-next';
import { defineStore } from 'pinia';
import {
getAccessCodesApi,
getCaptchaApi,
getUserInfoApi,
loginApi,
logoutApi,
} from '#/api';
import { $t } from '#/locales';
import { useDictStore, useWebSocketStore } from '#/store';
export const useAuthStore = defineStore('auth', () => {
const accessStore = useAccessStore();
const userStore = useUserStore();
const dictStore = useDictStore();
const router = useRouter();
const loginLoading = ref(false);
/**
* 登陆验证码
*/
async function captcha() {
const res: CaptchaResult = await getCaptchaApi();
accessStore.setCaptchaUuid(res.uuid);
return res;
}
/**
* 异步处理登录操作
* Asynchronously handle the login process
* @param params 登录表单数据
*/
async function authLogin(
params: Recordable<any>,
onSuccess?: () => Promise<void> | void,
) {
// 异步处理用户登录操作并获取 accessToken
let userInfo: MyUserInfo | null = null;
try {
loginLoading.value = true;
const { access_token, session_uuid } = await loginApi(
params as LoginParams,
);
// 如果成功获取到 accessToken
if (access_token) {
accessStore.setAccessToken(access_token);
accessStore.setAccessSessionUuid(session_uuid);
// 获取用户信息并存储到 accessStore 中
const [fetchUserInfoResult, accessCodes] = await Promise.all([
fetchUserInfo(),
getAccessCodesApi(),
]);
userInfo = fetchUserInfoResult;
userStore.setUserInfo(userInfo);
accessStore.setAccessCodes(accessCodes);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
} else {
onSuccess
? await onSuccess?.()
: await router.push(
userInfo.homePath || preferences.app.defaultHomePath,
);
}
// 初始化WebSocket连接
const wsStore = useWebSocketStore();
wsStore.connect();
if (userInfo?.nickname) {
notification.success({
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
duration: 3,
title: $t('authentication.loginSuccess'),
});
}
}
} finally {
loginLoading.value = false;
}
return {
userInfo,
};
}
async function oauth2Login() {
const params = new URLSearchParams(window.location.search);
const access_token = params.get('access_token');
const session_uuid = params.get('session_uuid');
if (access_token && session_uuid) {
accessStore.setAccessToken(access_token);
accessStore.setAccessSessionUuid(session_uuid);
return true;
}
console.error('Missing or invalid access_token or session_uuid');
return false;
}
async function logout(redirect: boolean = true) {
try {
await logoutApi();
} catch {
// 不做任何处理
}
resetAllStores();
accessStore.setLoginExpired(false);
// 回登录页带上当前路由地址
await router.replace({
path: LOGIN_PATH,
query: redirect
? {
redirect: encodeURIComponent(router.currentRoute.value.fullPath),
}
: {},
});
}
async function fetchUserInfo() {
const userInfo = await getUserInfoApi();
userStore.setUserInfo(userInfo);
dictStore.resetCache();
return userInfo;
}
function $reset() {
loginLoading.value = false;
}
return {
$reset,
captcha,
authLogin,
oauth2Login,
fetchUserInfo,
loginLoading,
logout,
};
});
@@ -0,0 +1,100 @@
import type { DictDataResult } from '#/plugins/dict/api';
import type { DictOptionsParams } from '#/utils/dict';
import { reactive } from 'vue';
import { $t } from '@vben/locales';
import { defineStore } from 'pinia';
import { generateDictCacheKey } from '#/utils/dict';
export interface DictOption {
disabled?: boolean;
label: string;
value: boolean | number | string;
color?: string;
}
export function dictToOptions(
data: DictDataResult[],
params: DictOptionsParams,
): DictOption[] {
const { asBoolean = false, asNumber = false, asString = false } = params;
return data.map((item) => {
let value: boolean | number | string = item.value;
if (asBoolean) {
value = item.value === 'true';
} else if (asNumber) {
value = Number(item.value);
} else if (asString) {
// asString 时保持原样,因为 value 本身就是 string 类型
value = item.value;
}
return {
disabled: item.status === 0,
label: $t(item.label),
value,
color: item.color,
};
});
}
export const useDictStore = defineStore('dict', () => {
const dictOptionsMap = reactive(new Map<string, DictOption[]>());
const dictRequestCache = reactive(
new Map<string, Promise<DictDataResult[]>>(),
);
function getDictOptions(
dictName: string,
params: DictOptionsParams,
): DictOption[] {
if (!dictName) return [];
const cacheKey = generateDictCacheKey(dictName, params);
if (!dictOptionsMap.has(cacheKey)) {
dictOptionsMap.set(cacheKey, []);
}
return dictOptionsMap.get(cacheKey) || [];
}
function setDictInfo(
dictName: string,
dictValue: DictDataResult[],
params: DictOptionsParams,
) {
const cacheKey = generateDictCacheKey(dictName, params);
if (
dictOptionsMap.has(cacheKey) &&
dictOptionsMap.get(cacheKey)?.length === 0
) {
dictOptionsMap.get(cacheKey)?.push(...dictToOptions(dictValue, params));
} else {
dictOptionsMap.set(cacheKey, dictToOptions(dictValue, params));
}
}
function resetCache() {
dictOptionsMap.clear();
dictRequestCache.clear();
}
function $reset() {
// doNothing
}
return {
$reset,
dictOptionsMap,
dictRequestCache,
getDictOptions,
setDictInfo,
resetCache,
};
});
@@ -0,0 +1,3 @@
export * from './auth';
export * from './dict';
export * from './websocket';
@@ -0,0 +1,252 @@
// stores/websocket.ts
import type { Socket } from 'socket.io-client';
import { computed, ref } from 'vue';
import { useAccessStore } from '@vben/stores';
import { defineStore } from 'pinia';
import { io } from 'socket.io-client';
export const useWebSocketStore = defineStore('websocket', () => {
const socket = ref<null | Socket>(null);
const isConnected = ref(false);
const eventCleanupFunctions = ref<Array<() => void>>([]);
const reconnectCallbacks = ref(new Set<() => void>());
// 连接配置常量
const WS_URL = import.meta.env.VITE_GLOB_API_URL;
const WS_PATH = '/ws/socket.io';
// 连接配置参数
const WS_CONFIG = {
autoConnect: true,
path: WS_PATH,
reconnection: true,
reconnectionAttempts: 3,
reconnectionDelay: 1000,
transports: ['websocket'],
};
/**
* 建立 WebSocket 连接
* @returns {boolean} 连接是否成功
*/
const connect = () => {
const accessStore = useAccessStore();
// 检查登录状态
if (!accessStore.accessToken) {
console.warn('用户未登录或登录已过期,无法建立 WebSocket 连接');
return false;
}
// 检查是否已连接
if (isConnected.value) {
return true;
}
// 如果有 socket 实例但未连接,尝试连接
if (socket.value && !isConnected.value) {
socket.value.connect();
return true;
}
try {
// console.log('正在初始化 WebSocket 连接...');
// 创建 Socket 连接,携带认证信息
socket.value = io(WS_URL, {
...WS_CONFIG,
auth: {
session_uuid: accessStore.accessSessionUuid,
token: accessStore.accessToken,
},
});
// 注册核心事件监听器
registerCoreEvents();
return true;
} catch (error) {
console.error('WebSocket 初始化失败:', error);
return false;
}
};
/**
* 注册核心事件监听器
*/
const registerCoreEvents = () => {
if (!socket.value) return;
const onConnect = () => {
// console.log('WebSocket 连接成功');
isConnected.value = true;
};
const onConnectError = (error: Error) => {
console.error('WebSocket 连接错误:', error);
isConnected.value = false;
handleConnectionError();
};
const onDisconnect = (reason: string) => {
console.warn('WebSocket 已断开:', reason);
isConnected.value = false;
};
socket.value.on('connect', onConnect);
socket.value.on('connect_error', onConnectError);
socket.value.on('disconnect', onDisconnect);
// 监听 Manager 的重连事件
socket.value.io.on('reconnect_attempt', (attempt: number) => {
console.warn(
`WebSocket 重连尝试 ${attempt}/${WS_CONFIG.reconnectionAttempts}`,
);
});
socket.value.io.on('reconnect', () => {
isConnected.value = true;
reconnectCallbacks.value.forEach((callback) => callback());
});
socket.value.io.on('reconnect_failed', () => {
console.error('WebSocket 重连失败,已达到最大重连次数');
isConnected.value = false;
});
// 保存核心事件到清理列表
eventCleanupFunctions.value.push(
() => socket.value?.off('connect', onConnect),
() => socket.value?.off('connect_error', onConnectError),
() => socket.value?.off('disconnect', onDisconnect),
);
};
/**
* 处理连接错误
*/
const handleConnectionError = () => {
const accessStore = useAccessStore();
if (!accessStore.accessToken) {
disconnect();
}
};
/**
* 发送消息
* @param event 事件名称
* @param data 发送的数据
*/
const emit = (event: string, data?: any): boolean => {
if (!socket.value || !isConnected.value) {
console.warn('WebSocket 未连接');
return false;
}
try {
socket.value.emit(event, data);
return true;
} catch (error) {
console.error('发送消息失败:', error);
return false;
}
};
/**
* 监听事件
* @param event 事件名称
* @param callback 回调函数
*/
const on = (event: string, callback: (data: any) => void) => {
if (!socket.value) {
console.warn('WebSocket 未初始化');
return () => {};
}
socket.value.on(event, callback);
// 保存清理函数
const cleanup = () => {
socket.value?.off(event, callback);
};
eventCleanupFunctions.value.push(cleanup);
return cleanup; // 返回清理函数,便于手动清理
};
/**
* 监听 WebSocket 重连成功事件
* @param callback 重连成功后的回调
*/
const onReconnect = (callback: () => void) => {
reconnectCallbacks.value.add(callback);
return () => {
reconnectCallbacks.value.delete(callback);
};
};
/**
* 移除事件监听
* @param event 事件名称
* @param callback 可选,特定回调函数
*/
const off = (event: string, callback?: any) => {
if (!socket.value) {
console.warn('WebSocket 未初始化');
return;
}
if (callback) {
socket.value.off(event, callback);
} else {
socket.value.off(event);
}
};
/**
* 清理所有注册的事件监听
*/
const cleanupEvents = () => {
eventCleanupFunctions.value.forEach((cleanup) => cleanup());
eventCleanupFunctions.value = [];
};
/**
* 断开 WebSocket 连接
*/
const disconnect = () => {
if (socket.value) {
cleanupEvents();
socket.value.disconnect();
socket.value = null;
isConnected.value = false;
}
};
// 连接状态计算属性
const connectionStatus = computed(() => {
if (!socket.value) return 'disconnected';
if (isConnected.value) return 'connected';
return socket.value.connected ? 'connected' : 'connecting';
});
function $reset() {
disconnect();
}
return {
$reset,
socket,
isConnected,
connectionStatus,
connect,
disconnect,
emit,
on,
onReconnect,
off,
cleanupEvents,
};
});

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