chore: vendor FBA UI 工程源码(fastapi-best-architecture-ui master,Epic #163 受控源码基础)
This commit is contained in:
@@ -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}`,
|
||||
);
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"menu": "Code Generator"
|
||||
}
|
||||
+3
@@ -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;
|
||||
+266
@@ -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>
|
||||
+199
@@ -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
Reference in New Issue
Block a user