chore: vendor FBA UI 工程源码(fastapi-best-architecture-ui master,Epic #163 受控源码基础)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
> 1%
|
||||||
|
last 2 versions
|
||||||
|
not dead
|
||||||
|
not ie 11
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from '@vben/commitlint-config';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
dist
|
||||||
|
.turbo
|
||||||
|
dist.zip
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset=utf-8
|
||||||
|
end_of_line=lf
|
||||||
|
insert_final_newline=true
|
||||||
|
indent_style=space
|
||||||
|
indent_size=2
|
||||||
|
max_line_length = 100
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
quote_type = single
|
||||||
|
|
||||||
|
[*.{yml,yaml,json}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# https://docs.github.com/cn/get-started/getting-started-with-git/configuring-git-to-handle-line-endings
|
||||||
|
|
||||||
|
# Automatically normalize line endings (to LF) for all text-based files.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Declare files that will always have CRLF line endings on checkout.
|
||||||
|
*.{cmd,[cC][mM][dD]} text eol=crlf
|
||||||
|
*.{bat,[bB][aA][tT]} text eol=crlf
|
||||||
|
|
||||||
|
# Denote all files that are truly binary and should not be modified.
|
||||||
|
*.{ico,png,jpg,jpeg,gif,webp,svg,woff,woff2} binary
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[core]
|
||||||
|
ignorecase = false
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export default {
|
||||||
|
'*.md': ['prettier --cache --ignore-unknown --write'],
|
||||||
|
'*.vue': [
|
||||||
|
'prettier --write',
|
||||||
|
'eslint --cache --fix',
|
||||||
|
'stylelint --fix --allow-empty-input',
|
||||||
|
],
|
||||||
|
'*.{js,jsx,ts,tsx}': [
|
||||||
|
'prettier --cache --ignore-unknown --write',
|
||||||
|
'eslint --cache --fix',
|
||||||
|
],
|
||||||
|
'*.{scss,less,styl,html,vue,css}': [
|
||||||
|
'prettier --cache --ignore-unknown --write',
|
||||||
|
'stylelint --fix --allow-empty-input',
|
||||||
|
],
|
||||||
|
'package.json': ['prettier --cache --write'],
|
||||||
|
'{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': [
|
||||||
|
'prettier --cache --write--parser json',
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
24.16.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
registry=https://registry.npmmirror.com
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
dist
|
||||||
|
public
|
||||||
|
__tests__
|
||||||
|
coverage
|
||||||
|
.codex
|
||||||
|
.claude
|
||||||
|
.agent
|
||||||
|
.agents
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
FROM guergeiro/pnpm:lts-latest-slim AS build
|
||||||
|
|
||||||
|
WORKDIR /fba_ui
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN pnpm install \
|
||||||
|
&& pnpm build
|
||||||
|
|
||||||
|
FROM nginx
|
||||||
|
|
||||||
|
COPY scripts/deploy/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
COPY --from=build /fba_ui/apps/web-antdv-next/dist /var/www/fba_ui
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 FastAPI Practices
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# FastAPI Best Architecture UI
|
||||||
|
|
||||||
|
Front-end Implementation of the [FastAPI Best Architecture](https://github.com/fastapi-practices/fastapi_best_architecture)
|
||||||
|
|
||||||
|
## Help
|
||||||
|
|
||||||
|
For more details, please check the [official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/frontend/summary/quick-start.html)
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
<a href="https://github.com/fastapi-practices/fba_ui/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=fastapi-practices/fba_ui"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Special thanks
|
||||||
|
|
||||||
|
- [Vue.js](https://cn.vuejs.org/guide/introduction.html)
|
||||||
|
- [Vben Admin](https://www.vben.pro/)
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the terms of the [MIT](https://github.com/fastapi-practices/fba_ui/blob/master/LICENSE) license
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 应用标题
|
||||||
|
VITE_APP_TITLE=FBA UI
|
||||||
|
|
||||||
|
# 应用命名空间,用于缓存、store等功能的前缀,确保隔离
|
||||||
|
VITE_APP_NAMESPACE=fba-ui
|
||||||
|
|
||||||
|
# 对store进行加密的密钥,在将store持久化到localStorage时会使用该密钥进行加密
|
||||||
|
VITE_APP_STORE_SECURE_KEY=please-replace-me-with-your-own-key
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# public path
|
||||||
|
VITE_BASE=/
|
||||||
|
|
||||||
|
# Basic interface address SPA
|
||||||
|
VITE_GLOB_API_URL=http://localhost:8000
|
||||||
|
|
||||||
|
VITE_VISUALIZER=true
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# 端口号
|
||||||
|
VITE_PORT=5173
|
||||||
|
|
||||||
|
VITE_BASE=/
|
||||||
|
|
||||||
|
# 接口地址
|
||||||
|
VITE_GLOB_API_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# 是否开启 Nitro Mock服务,true 为开启,false 为关闭
|
||||||
|
VITE_NITRO_MOCK=false
|
||||||
|
|
||||||
|
# 是否打开 devtools,true 为打开,false 为关闭
|
||||||
|
VITE_DEVTOOLS=false
|
||||||
|
|
||||||
|
# 是否注入全局loading
|
||||||
|
VITE_INJECT_APP_LOADING=true
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
VITE_BASE=/
|
||||||
|
|
||||||
|
# 接口地址
|
||||||
|
VITE_GLOB_API_URL=https://fba.wu-clan.site
|
||||||
|
|
||||||
|
# 是否开启压缩,可以设置为 none, brotli, gzip
|
||||||
|
VITE_COMPRESS=gzip
|
||||||
|
|
||||||
|
# 是否开启 PWA
|
||||||
|
VITE_PWA=false
|
||||||
|
|
||||||
|
# vue-router 的模式
|
||||||
|
VITE_ROUTER_HISTORY=hash
|
||||||
|
|
||||||
|
# 是否注入全局loading
|
||||||
|
VITE_INJECT_APP_LOADING=true
|
||||||
|
|
||||||
|
# 打包后是否生成dist.zip
|
||||||
|
VITE_ARCHIVER=false
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||||
|
<meta name="renderer" content="webkit" />
|
||||||
|
<meta name="description" content="A Modern Back-end Management System" />
|
||||||
|
<meta name="keywords" content="Vben Admin Vue3 Vite" />
|
||||||
|
<meta name="author" content="Vben" />
|
||||||
|
<meta name="google-site-verification" content="......." />
|
||||||
|
<meta name="fba" content="Enterprise-level backend architecture solution" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
|
||||||
|
/>
|
||||||
|
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
|
||||||
|
<title>%VITE_APP_TITLE%</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "@vben/web-antdv-next",
|
||||||
|
"version": "5.7.0",
|
||||||
|
"homepage": "https://vben.pro",
|
||||||
|
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||||
|
"directory": "apps/web-antdv-next"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"author": {
|
||||||
|
"name": "vben",
|
||||||
|
"email": "ann.vben@gmail.com",
|
||||||
|
"url": "https://github.com/anncwb"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "pnpm vite build --mode production",
|
||||||
|
"build:analyze": "pnpm vite build --mode analyze",
|
||||||
|
"dev": "pnpm vite --mode development",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "vue-tsc --noEmit --skipLibCheck"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"#/*": "./src/*"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vben/access": "workspace:*",
|
||||||
|
"@vben/common-ui": "workspace:*",
|
||||||
|
"@vben/constants": "workspace:*",
|
||||||
|
"@vben/hooks": "workspace:*",
|
||||||
|
"@vben/icons": "workspace:*",
|
||||||
|
"@vben/layouts": "workspace:*",
|
||||||
|
"@vben/locales": "workspace:*",
|
||||||
|
"@vben/plugins": "workspace:*",
|
||||||
|
"@vben/preferences": "workspace:*",
|
||||||
|
"@vben/request": "workspace:*",
|
||||||
|
"@vben/stores": "workspace:*",
|
||||||
|
"@vben/styles": "workspace:*",
|
||||||
|
"@vben/types": "workspace:*",
|
||||||
|
"@vben/utils": "workspace:*",
|
||||||
|
"@vueuse/core": "catalog:",
|
||||||
|
"antdv-next": "catalog:",
|
||||||
|
"croner": "catalog:",
|
||||||
|
"dayjs": "catalog:",
|
||||||
|
"mitt": "catalog:",
|
||||||
|
"pinia": "catalog:",
|
||||||
|
"socket.io-client": "catalog:",
|
||||||
|
"vue": "catalog:",
|
||||||
|
"vue-router": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,788 @@
|
|||||||
|
/**
|
||||||
|
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
|
||||||
|
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable vue/one-component-per-file */
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AutoCompleteProps,
|
||||||
|
ButtonProps,
|
||||||
|
CascaderProps,
|
||||||
|
CheckboxGroupProps,
|
||||||
|
CheckboxProps,
|
||||||
|
DatePickerProps,
|
||||||
|
DividerProps,
|
||||||
|
InputNumberProps,
|
||||||
|
InputProps,
|
||||||
|
MentionsProps,
|
||||||
|
RadioGroupProps,
|
||||||
|
RadioProps,
|
||||||
|
RangePickerProps,
|
||||||
|
RateProps,
|
||||||
|
SelectProps,
|
||||||
|
SpaceProps,
|
||||||
|
SwitchProps,
|
||||||
|
TextAreaProps,
|
||||||
|
TimePickerProps,
|
||||||
|
TreeSelectProps,
|
||||||
|
UploadChangeParam,
|
||||||
|
UploadFile,
|
||||||
|
UploadProps,
|
||||||
|
} from 'antdv-next';
|
||||||
|
|
||||||
|
import type { Component, Ref } from 'vue';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ApiComponentSharedProps,
|
||||||
|
BaseFormComponentType,
|
||||||
|
CollapsibleParamsProps,
|
||||||
|
IconPickerProps,
|
||||||
|
} from '@vben/common-ui';
|
||||||
|
import type { Sortable } from '@vben/hooks';
|
||||||
|
import type { TipTapProps } from '@vben/plugins/tiptap';
|
||||||
|
import type { Recordable } from '@vben/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
defineAsyncComponent,
|
||||||
|
defineComponent,
|
||||||
|
h,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
render,
|
||||||
|
unref,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ApiComponent,
|
||||||
|
globalShareState,
|
||||||
|
IconPicker,
|
||||||
|
VbenCollapsibleParams,
|
||||||
|
VCropper,
|
||||||
|
} from '@vben/common-ui';
|
||||||
|
import { useSortable } from '@vben/hooks';
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
import { VbenTiptap } from '@vben/plugins/tiptap';
|
||||||
|
import { isEmpty } from '@vben/utils';
|
||||||
|
|
||||||
|
import { message, Modal, notification } from 'antdv-next';
|
||||||
|
|
||||||
|
import { upload_file } from '#/api';
|
||||||
|
type AdapterUploadProps = UploadProps & {
|
||||||
|
aspectRatio?: string;
|
||||||
|
crop?: boolean;
|
||||||
|
draggable?: boolean;
|
||||||
|
handleChange?: (event: UploadChangeParam) => void;
|
||||||
|
maxSize?: number;
|
||||||
|
onDragSort?: (oldIndex: number, newIndex: number) => void;
|
||||||
|
onHandleChange?: (event: UploadChangeParam) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AutoComplete = defineAsyncComponent(
|
||||||
|
() => import('antdv-next/dist/auto-complete/index'),
|
||||||
|
);
|
||||||
|
const Button = defineAsyncComponent(
|
||||||
|
() => import('antdv-next/dist/button/index'),
|
||||||
|
);
|
||||||
|
const Checkbox = defineAsyncComponent(
|
||||||
|
() => import('antdv-next/dist/checkbox/index'),
|
||||||
|
);
|
||||||
|
const CheckboxGroup = defineAsyncComponent(() =>
|
||||||
|
import('antdv-next/dist/checkbox/index').then((res) => res.CheckboxGroup),
|
||||||
|
);
|
||||||
|
const DatePicker = defineAsyncComponent(
|
||||||
|
() => import('antdv-next/dist/date-picker/index'),
|
||||||
|
);
|
||||||
|
const Divider = defineAsyncComponent(
|
||||||
|
() => import('antdv-next/dist/divider/index'),
|
||||||
|
);
|
||||||
|
const Input = defineAsyncComponent(() => import('antdv-next/dist/input/index'));
|
||||||
|
const InputNumber = defineAsyncComponent(
|
||||||
|
() => import('antdv-next/dist/input-number/index'),
|
||||||
|
);
|
||||||
|
const InputPassword = defineAsyncComponent(() =>
|
||||||
|
import('antdv-next/dist/input/index').then((res) => res.InputPassword),
|
||||||
|
);
|
||||||
|
const Mentions = defineAsyncComponent(
|
||||||
|
() => import('antdv-next/dist/mentions/index'),
|
||||||
|
);
|
||||||
|
const Radio = defineAsyncComponent(() => import('antdv-next/dist/radio/index'));
|
||||||
|
const RadioGroup = defineAsyncComponent(() =>
|
||||||
|
import('antdv-next/dist/radio/index').then((res) => res.RadioGroup),
|
||||||
|
);
|
||||||
|
const RangePicker = defineAsyncComponent(() =>
|
||||||
|
import('antdv-next/dist/date-picker/index').then(
|
||||||
|
(res) => res.DateRangePicker,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const Rate = defineAsyncComponent(() => import('antdv-next/dist/rate/index'));
|
||||||
|
const Select = defineAsyncComponent(
|
||||||
|
() => import('antdv-next/dist/select/index'),
|
||||||
|
);
|
||||||
|
const Space = defineAsyncComponent(() => import('antdv-next/dist/space/index'));
|
||||||
|
const Switch = defineAsyncComponent(
|
||||||
|
() => import('antdv-next/dist/switch/index'),
|
||||||
|
);
|
||||||
|
const Textarea = defineAsyncComponent(
|
||||||
|
() => import('antdv-next/dist/input/TextArea'),
|
||||||
|
);
|
||||||
|
const TimePicker = defineAsyncComponent(
|
||||||
|
() => import('antdv-next/dist/time-picker/index'),
|
||||||
|
);
|
||||||
|
const TreeSelect = defineAsyncComponent(
|
||||||
|
() => import('antdv-next/dist/tree-select/index'),
|
||||||
|
);
|
||||||
|
const Cascader = defineAsyncComponent(
|
||||||
|
() => import('antdv-next/dist/cascader/index'),
|
||||||
|
);
|
||||||
|
const Upload = defineAsyncComponent(
|
||||||
|
() => import('antdv-next/dist/upload/index'),
|
||||||
|
);
|
||||||
|
const Image = defineAsyncComponent(() => import('antdv-next/dist/image/index'));
|
||||||
|
const PreviewGroup = defineAsyncComponent(() =>
|
||||||
|
import('antdv-next/dist/image/index').then((res) => res.ImagePreviewGroup),
|
||||||
|
);
|
||||||
|
|
||||||
|
const withDefaultPlaceholder = (
|
||||||
|
component: Component,
|
||||||
|
type: 'input' | 'select',
|
||||||
|
componentProps: Recordable<any> = {},
|
||||||
|
) => {
|
||||||
|
return defineComponent({
|
||||||
|
name: component.name,
|
||||||
|
inheritAttrs: false,
|
||||||
|
setup: (props: any, { attrs, expose, slots }) => {
|
||||||
|
const placeholder =
|
||||||
|
props?.placeholder ||
|
||||||
|
attrs?.placeholder ||
|
||||||
|
$t(`ui.placeholder.${type}`);
|
||||||
|
// 透传组件暴露的方法
|
||||||
|
const innerRef = ref();
|
||||||
|
expose(
|
||||||
|
new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: (_target, key) => innerRef.value?.[key],
|
||||||
|
has: (_target, key) => key in (innerRef.value || {}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
component,
|
||||||
|
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
|
||||||
|
slots,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = new Set([
|
||||||
|
'bmp',
|
||||||
|
'gif',
|
||||||
|
'jpeg',
|
||||||
|
'jpg',
|
||||||
|
'png',
|
||||||
|
'svg',
|
||||||
|
'webp',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为图片文件
|
||||||
|
*/
|
||||||
|
function isImageFile(file: UploadFile): boolean {
|
||||||
|
if (file.url) {
|
||||||
|
try {
|
||||||
|
const pathname = new URL(file.url, 'http://localhost').pathname;
|
||||||
|
const ext = pathname.split('.').pop()?.toLowerCase();
|
||||||
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||||
|
} catch {
|
||||||
|
const ext = file.url?.split('.').pop()?.toLowerCase();
|
||||||
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!file.type) {
|
||||||
|
const ext = file.name?.split('.').pop()?.toLowerCase();
|
||||||
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||||
|
}
|
||||||
|
return file.type.startsWith('image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建默认的上传按钮插槽
|
||||||
|
*/
|
||||||
|
function createDefaultUploadSlots(listType: string, placeholder: string) {
|
||||||
|
if (listType === 'picture-card') {
|
||||||
|
return { default: () => placeholder };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
default: () =>
|
||||||
|
h(
|
||||||
|
Button,
|
||||||
|
{
|
||||||
|
icon: h(IconifyIcon, {
|
||||||
|
icon: 'ant-design:upload-outlined',
|
||||||
|
class: 'mb-1 size-4',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
() => placeholder,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件的 Base64
|
||||||
|
*/
|
||||||
|
function getBase64(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
reader.addEventListener('load', () => resolve(reader.result as string));
|
||||||
|
reader.addEventListener('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览图片
|
||||||
|
*/
|
||||||
|
async function previewImage(
|
||||||
|
file: UploadFile,
|
||||||
|
open: Ref<boolean>,
|
||||||
|
fileList: Ref<UploadProps['fileList']>,
|
||||||
|
) {
|
||||||
|
// 非图片文件直接打开链接
|
||||||
|
if (!isImageFile(file)) {
|
||||||
|
const url = file.url || file.preview;
|
||||||
|
if (url) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
} else if (file.preview) {
|
||||||
|
window.open(file.preview, '_blank');
|
||||||
|
} else {
|
||||||
|
message.error($t('ui.formRules.previewWarning'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
|
||||||
|
Image,
|
||||||
|
PreviewGroup,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 过滤图片文件并生成预览
|
||||||
|
const imageFiles = (unref(fileList) || []).filter((f) => isImageFile(f));
|
||||||
|
|
||||||
|
for (const imgFile of imageFiles) {
|
||||||
|
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
|
||||||
|
imgFile.preview = await getBase64(imgFile.originFileObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
let isUnmounted = false;
|
||||||
|
|
||||||
|
const currentIndex = imageFiles.findIndex((f) => f.uid === file.uid);
|
||||||
|
|
||||||
|
const PreviewWrapper = {
|
||||||
|
setup() {
|
||||||
|
return () => {
|
||||||
|
if (isUnmounted) return null;
|
||||||
|
return h(
|
||||||
|
PreviewGroupComponent,
|
||||||
|
{
|
||||||
|
class: 'hidden',
|
||||||
|
preview: {
|
||||||
|
open: open.value,
|
||||||
|
current: currentIndex,
|
||||||
|
onOpenChange: (value: boolean) => {
|
||||||
|
open.value = value;
|
||||||
|
if (!value) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isUnmounted && container) {
|
||||||
|
isUnmounted = true;
|
||||||
|
render(null, container);
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
() =>
|
||||||
|
imageFiles.map((imgFile) =>
|
||||||
|
h(ImageComponent, {
|
||||||
|
key: imgFile.uid,
|
||||||
|
src: imgFile.url || imgFile.preview,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(h(PreviewWrapper), container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片裁剪操作
|
||||||
|
*/
|
||||||
|
function cropImage(file: File, aspectRatio: string | undefined) {
|
||||||
|
return new Promise<Blob | string | undefined>((resolve, reject) => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
|
||||||
|
let isUnmounted = false;
|
||||||
|
let objectUrl: null | string = null;
|
||||||
|
|
||||||
|
const open = ref<boolean>(true);
|
||||||
|
const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
open.value = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isUnmounted && container) {
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
isUnmounted = true;
|
||||||
|
render(null, container);
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CropperWrapper = {
|
||||||
|
setup() {
|
||||||
|
return () => {
|
||||||
|
if (isUnmounted) return null;
|
||||||
|
if (!objectUrl) {
|
||||||
|
objectUrl = URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
return h(
|
||||||
|
Modal,
|
||||||
|
{
|
||||||
|
open: open.value,
|
||||||
|
title: h('div', {}, [
|
||||||
|
$t('ui.crop.title'),
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
|
||||||
|
},
|
||||||
|
$t('ui.crop.titleTip', [aspectRatio]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
centered: true,
|
||||||
|
width: 548,
|
||||||
|
keyboard: false,
|
||||||
|
maskClosable: false,
|
||||||
|
closable: false,
|
||||||
|
cancelText: $t('common.cancel'),
|
||||||
|
okText: $t('ui.crop.confirm'),
|
||||||
|
destroyOnHidden: true,
|
||||||
|
onOk: async () => {
|
||||||
|
const cropper = cropperRef.value;
|
||||||
|
if (!cropper) {
|
||||||
|
reject(new Error('Cropper not found'));
|
||||||
|
closeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const dataUrl = await cropper.getCropImage();
|
||||||
|
if (dataUrl) {
|
||||||
|
resolve(dataUrl);
|
||||||
|
} else {
|
||||||
|
reject(new Error($t('ui.crop.errorTip')));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reject(new Error($t('ui.crop.errorTip')));
|
||||||
|
} finally {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel() {
|
||||||
|
resolve('');
|
||||||
|
closeModal();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
() =>
|
||||||
|
h(VCropper, {
|
||||||
|
ref: (ref: any) => (cropperRef.value = ref),
|
||||||
|
img: objectUrl as string,
|
||||||
|
aspectRatio,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(h(CropperWrapper), container);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带预览功能的上传组件
|
||||||
|
*/
|
||||||
|
function withPreviewUpload() {
|
||||||
|
return defineComponent({
|
||||||
|
name: Upload.name,
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(
|
||||||
|
props: any,
|
||||||
|
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
|
||||||
|
) {
|
||||||
|
const previewVisible = ref<boolean>(false);
|
||||||
|
const placeholder = attrs?.placeholder || $t('ui.placeholder.upload');
|
||||||
|
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
|
||||||
|
const fileList = ref<UploadProps['fileList']>(
|
||||||
|
attrs?.fileList || attrs?.['file-list'] || [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxSize = computed(() => attrs?.maxSize ?? attrs?.['max-size']);
|
||||||
|
const aspectRatio = computed(
|
||||||
|
() => attrs?.aspectRatio ?? attrs?.['aspect-ratio'],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleBeforeUpload(
|
||||||
|
file: UploadFile,
|
||||||
|
originFileList: Array<File>,
|
||||||
|
) {
|
||||||
|
// 文件大小限制
|
||||||
|
if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) {
|
||||||
|
message.error($t('ui.formRules.sizeLimit', [maxSize.value]));
|
||||||
|
file.status = 'removed';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片裁剪处理
|
||||||
|
if (
|
||||||
|
attrs.crop &&
|
||||||
|
!attrs.multiple &&
|
||||||
|
originFileList[0] &&
|
||||||
|
isImageFile(file)
|
||||||
|
) {
|
||||||
|
file.status = 'removed';
|
||||||
|
const blob = await cropImage(originFileList[0], aspectRatio.value);
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error($t('ui.crop.errorTip'));
|
||||||
|
}
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs.beforeUpload?.(file) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(event: UploadChangeParam) {
|
||||||
|
try {
|
||||||
|
attrs.handleChange?.(event);
|
||||||
|
attrs.onHandleChange?.(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
fileList.value = event.fileList.filter(
|
||||||
|
(file) => file.status !== 'removed',
|
||||||
|
);
|
||||||
|
emit(
|
||||||
|
'update:modelValue',
|
||||||
|
event.fileList?.length ? fileList.value : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreview(file: UploadFile) {
|
||||||
|
previewVisible.value = true;
|
||||||
|
return previewImage(file, previewVisible, fileList);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUploadButton() {
|
||||||
|
if (attrs.disabled) return null;
|
||||||
|
return isEmpty(slots)
|
||||||
|
? createDefaultUploadSlots(listType, placeholder)
|
||||||
|
: slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽排序
|
||||||
|
const draggable = computed(
|
||||||
|
() => (attrs.draggable ?? false) && !attrs.disabled,
|
||||||
|
);
|
||||||
|
const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
const sortableInstance = ref<null | Sortable>(null);
|
||||||
|
|
||||||
|
const styleId = `upload-drag-style-${uploadId}`;
|
||||||
|
|
||||||
|
function injectDragStyle() {
|
||||||
|
if (!document.querySelector(`[id="${styleId}"]`)) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = styleId;
|
||||||
|
style.textContent = `
|
||||||
|
[data-upload-id="${uploadId}"] .ant-upload-list-item { cursor: move; }
|
||||||
|
[data-upload-id="${uploadId}"] .ant-upload-list-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||||
|
`;
|
||||||
|
document.head.append(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDragStyle() {
|
||||||
|
document.querySelector(`[id="${styleId}"]`)?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSortable(retryCount = 0) {
|
||||||
|
if (!draggable.value) return;
|
||||||
|
|
||||||
|
injectDragStyle();
|
||||||
|
await nextTick();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const container = document.querySelector(
|
||||||
|
`[data-upload-id="${uploadId}"] .ant-upload-list`,
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
if (retryCount < 5) {
|
||||||
|
setTimeout(() => initSortable(retryCount + 1), 200);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { initializeSortable } = useSortable(container, {
|
||||||
|
animation: 300,
|
||||||
|
delay: 400,
|
||||||
|
delayOnTouchOnly: true,
|
||||||
|
filter:
|
||||||
|
'.ant-upload-select, .ant-upload-list-item-error, .ant-upload-list-item-uploading',
|
||||||
|
onEnd: (evt) => {
|
||||||
|
const { oldIndex, newIndex } = evt;
|
||||||
|
if (
|
||||||
|
oldIndex === undefined ||
|
||||||
|
newIndex === undefined ||
|
||||||
|
oldIndex === newIndex
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = [...(fileList.value || [])];
|
||||||
|
const [movedItem] = list.splice(oldIndex, 1);
|
||||||
|
if (movedItem) {
|
||||||
|
list.splice(newIndex, 0, movedItem);
|
||||||
|
fileList.value = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs.onDragSort?.(oldIndex, newIndex);
|
||||||
|
emit('update:modelValue', fileList.value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
sortableInstance.value = await initializeSortable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听表单值变化
|
||||||
|
watch(
|
||||||
|
() => attrs.modelValue,
|
||||||
|
(res) => {
|
||||||
|
fileList.value = res;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(initSortable);
|
||||||
|
onUnmounted(() => {
|
||||||
|
sortableInstance.value?.destroy();
|
||||||
|
removeDragStyle();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ 'data-upload-id': uploadId, class: 'w-full' },
|
||||||
|
h(
|
||||||
|
Upload,
|
||||||
|
{
|
||||||
|
...props,
|
||||||
|
...attrs,
|
||||||
|
fileList: fileList.value,
|
||||||
|
beforeUpload: handleBeforeUpload,
|
||||||
|
onChange: handleChange,
|
||||||
|
onPreview: handlePreview,
|
||||||
|
},
|
||||||
|
renderUploadButton() as any,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
||||||
|
export type ComponentType =
|
||||||
|
| 'ApiCascader'
|
||||||
|
| 'ApiSelect'
|
||||||
|
| 'ApiTreeSelect'
|
||||||
|
| 'AutoComplete'
|
||||||
|
| 'Cascader'
|
||||||
|
| 'Checkbox'
|
||||||
|
| 'CheckboxGroup'
|
||||||
|
| 'CollapsibleParams'
|
||||||
|
| 'DatePicker'
|
||||||
|
| 'DefaultButton'
|
||||||
|
| 'Divider'
|
||||||
|
| 'IconPicker'
|
||||||
|
| 'Input'
|
||||||
|
| 'InputNumber'
|
||||||
|
| 'InputPassword'
|
||||||
|
| 'Mentions'
|
||||||
|
| 'PrimaryButton'
|
||||||
|
| 'Radio'
|
||||||
|
| 'RadioGroup'
|
||||||
|
| 'RangePicker'
|
||||||
|
| 'Rate'
|
||||||
|
| 'RichEditor'
|
||||||
|
| 'Select'
|
||||||
|
| 'Space'
|
||||||
|
| 'Switch'
|
||||||
|
| 'Textarea'
|
||||||
|
| 'TimePicker'
|
||||||
|
| 'TreeSelect'
|
||||||
|
| 'Upload'
|
||||||
|
| BaseFormComponentType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 {@link ComponentType} 中注册的组件名一一对应,便于 Schema 上 `component` + `componentProps` 联动提示
|
||||||
|
*/
|
||||||
|
export interface ComponentPropsMap {
|
||||||
|
ApiCascader: ApiComponentSharedProps & CascaderProps;
|
||||||
|
ApiSelect: ApiComponentSharedProps & SelectProps;
|
||||||
|
ApiTreeSelect: ApiComponentSharedProps & TreeSelectProps;
|
||||||
|
AutoComplete: AutoCompleteProps;
|
||||||
|
Cascader: CascaderProps;
|
||||||
|
Checkbox: CheckboxProps;
|
||||||
|
CheckboxGroup: CheckboxGroupProps;
|
||||||
|
CollapsibleParams: CollapsibleParamsProps;
|
||||||
|
DatePicker: DatePickerProps;
|
||||||
|
DefaultButton: ButtonProps;
|
||||||
|
Divider: DividerProps;
|
||||||
|
IconPicker: IconPickerProps;
|
||||||
|
Input: InputProps;
|
||||||
|
InputNumber: InputNumberProps;
|
||||||
|
InputPassword: InputProps;
|
||||||
|
Mentions: MentionsProps;
|
||||||
|
PrimaryButton: ButtonProps;
|
||||||
|
Radio: RadioProps;
|
||||||
|
RadioGroup: RadioGroupProps;
|
||||||
|
RangePicker: RangePickerProps;
|
||||||
|
Rate: RateProps;
|
||||||
|
RichEditor: TipTapProps;
|
||||||
|
Select: SelectProps;
|
||||||
|
Space: SpaceProps;
|
||||||
|
Switch: SwitchProps;
|
||||||
|
Textarea: TextAreaProps;
|
||||||
|
TimePicker: TimePickerProps;
|
||||||
|
TreeSelect: TreeSelectProps;
|
||||||
|
Upload: AdapterUploadProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initComponentAdapter() {
|
||||||
|
const components: Partial<Record<ComponentType, Component>> = {
|
||||||
|
// 如果你的组件体积比较大,可以使用异步加载
|
||||||
|
// Button: () =>
|
||||||
|
// import('xxx').then((res) => res.Button),
|
||||||
|
|
||||||
|
ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||||
|
component: Cascader,
|
||||||
|
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||||
|
loadingSlot: 'suffixIcon',
|
||||||
|
modelPropName: 'value',
|
||||||
|
visibleEvent: 'onOpenChange',
|
||||||
|
}),
|
||||||
|
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||||
|
component: Select,
|
||||||
|
loadingSlot: 'suffixIcon',
|
||||||
|
modelPropName: 'value',
|
||||||
|
visibleEvent: 'onOpenChange',
|
||||||
|
}),
|
||||||
|
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||||
|
component: TreeSelect,
|
||||||
|
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||||
|
loadingSlot: 'suffixIcon',
|
||||||
|
modelPropName: 'value',
|
||||||
|
optionsPropName: 'treeData',
|
||||||
|
visibleEvent: 'onOpenChange',
|
||||||
|
}),
|
||||||
|
AutoComplete,
|
||||||
|
Cascader,
|
||||||
|
Checkbox,
|
||||||
|
CheckboxGroup,
|
||||||
|
DatePicker,
|
||||||
|
// 自定义默认按钮
|
||||||
|
DefaultButton: (props, { attrs, slots }) => {
|
||||||
|
return h(Button, { ...props, attrs, type: 'default' }, slots);
|
||||||
|
},
|
||||||
|
Divider,
|
||||||
|
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
|
||||||
|
iconSlot: 'addonAfter',
|
||||||
|
inputComponent: Input,
|
||||||
|
modelValueProp: 'value',
|
||||||
|
}),
|
||||||
|
Input: withDefaultPlaceholder(Input, 'input'),
|
||||||
|
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
|
||||||
|
style: { width: '100%' },
|
||||||
|
}),
|
||||||
|
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
||||||
|
Mentions: withDefaultPlaceholder(Mentions, 'input'),
|
||||||
|
// 自定义主要按钮
|
||||||
|
PrimaryButton: (props, { attrs, slots }) => {
|
||||||
|
return h(Button, { ...props, attrs, type: 'primary' }, slots);
|
||||||
|
},
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
RangePicker,
|
||||||
|
Rate,
|
||||||
|
RichEditor: withDefaultPlaceholder(VbenTiptap, 'input', {
|
||||||
|
imageUpload: {
|
||||||
|
upload: (file: any, onProgress: any) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
upload_file({
|
||||||
|
file,
|
||||||
|
onProgress({ percent }) {
|
||||||
|
onProgress?.(percent);
|
||||||
|
},
|
||||||
|
onSuccess(response) {
|
||||||
|
// 从响应中提取图片URL
|
||||||
|
resolve(response?.data?.url ?? response?.url ?? '');
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
reject(new Error($t('ui.tiptap.upload.uploadFailed')));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Select: withDefaultPlaceholder(Select, 'select'),
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Textarea: withDefaultPlaceholder(Textarea, 'input'),
|
||||||
|
TimePicker,
|
||||||
|
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
|
||||||
|
Upload: withPreviewUpload(),
|
||||||
|
CollapsibleParams: VbenCollapsibleParams,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 将组件注册到全局共享状态中
|
||||||
|
globalShareState.setComponents(components);
|
||||||
|
|
||||||
|
// 定义全局共享状态中的消息提示
|
||||||
|
globalShareState.defineMessage({
|
||||||
|
// 复制成功消息提示
|
||||||
|
copyPreferencesSuccess: (title, content) => {
|
||||||
|
notification.success({
|
||||||
|
description: content,
|
||||||
|
title,
|
||||||
|
placement: 'bottomRight',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { initComponentAdapter };
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type {
|
||||||
|
VbenFormProps as FormProps,
|
||||||
|
VbenFormSchema as FormSchema,
|
||||||
|
} from '@vben/common-ui';
|
||||||
|
|
||||||
|
import type { ComponentPropsMap, ComponentType } from './component';
|
||||||
|
|
||||||
|
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
async function initSetupVbenForm() {
|
||||||
|
setupVbenForm<ComponentType>({
|
||||||
|
config: {
|
||||||
|
// ant design vue组件库默认都是 v-model:value
|
||||||
|
baseModelPropName: 'value',
|
||||||
|
|
||||||
|
// 一些组件是 v-model:checked 或者 v-model:fileList
|
||||||
|
modelPropNameMap: {
|
||||||
|
Checkbox: 'checked',
|
||||||
|
Radio: 'checked',
|
||||||
|
Switch: 'checked',
|
||||||
|
Upload: 'fileList',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defineRules: {
|
||||||
|
// 输入项目必填国际化适配
|
||||||
|
required: (value, _params, ctx) => {
|
||||||
|
if (value === undefined || value === null || value.length === 0) {
|
||||||
|
return $t('ui.formRules.required', [ctx.label]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
// 选择项目必填国际化适配
|
||||||
|
selectRequired: (value, _params, ctx) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return $t('ui.formRules.selectRequired', [ctx.label]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
|
||||||
|
|
||||||
|
export { initSetupVbenForm, useVbenForm, z };
|
||||||
|
|
||||||
|
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
|
||||||
|
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
|
||||||
|
import type { Recordable } from '@vben/types';
|
||||||
|
|
||||||
|
import type { ComponentPropsMap, ComponentType } from './component';
|
||||||
|
|
||||||
|
import { h } from 'vue';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
import { $te } from '@vben/locales';
|
||||||
|
import {
|
||||||
|
setupVbenVxeTable,
|
||||||
|
useVbenVxeGrid as useGrid,
|
||||||
|
} from '@vben/plugins/vxe-table';
|
||||||
|
import { get, isFunction, isString } from '@vben/utils';
|
||||||
|
|
||||||
|
import { objectOmit } from '@vueuse/core';
|
||||||
|
import { Button, Dropdown, Image, Popconfirm, Switch, Tag } from 'antdv-next';
|
||||||
|
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
import { DictEnum, getDictOptions } from '#/utils/dict';
|
||||||
|
|
||||||
|
import { useVbenForm } from './form';
|
||||||
|
|
||||||
|
setupVbenVxeTable({
|
||||||
|
configVxeTable: (vxeUI) => {
|
||||||
|
vxeUI.setConfig({
|
||||||
|
grid: {
|
||||||
|
align: 'center',
|
||||||
|
border: false,
|
||||||
|
columnConfig: {
|
||||||
|
resizable: true,
|
||||||
|
},
|
||||||
|
minHeight: 180,
|
||||||
|
formConfig: {
|
||||||
|
// 全局禁用vxe-table的表单配置,使用formOptions
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
proxyConfig: {
|
||||||
|
autoLoad: true,
|
||||||
|
response: {
|
||||||
|
result: 'items',
|
||||||
|
total: 'total',
|
||||||
|
list: '',
|
||||||
|
},
|
||||||
|
showActiveMsg: true,
|
||||||
|
showResponseMsg: false,
|
||||||
|
},
|
||||||
|
round: true,
|
||||||
|
showOverflow: true,
|
||||||
|
size: 'small',
|
||||||
|
} as VxeTableGridOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表格配置项可以用 cellRender: { name: 'CellImage' },
|
||||||
|
vxeUI.renderer.add('CellImage', {
|
||||||
|
renderTableDefault(renderOpts, params) {
|
||||||
|
const { props } = renderOpts;
|
||||||
|
const { column, row } = params;
|
||||||
|
return h(Image, { src: row[column.field], ...props });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表格配置项可以用 cellRender: { name: 'CellLink' },
|
||||||
|
vxeUI.renderer.add('CellLink', {
|
||||||
|
renderTableDefault(renderOpts) {
|
||||||
|
const { props } = renderOpts;
|
||||||
|
return h(
|
||||||
|
Button,
|
||||||
|
{ size: 'small', type: 'link' },
|
||||||
|
{ default: () => props?.text },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 单元格渲染:Tag
|
||||||
|
vxeUI.renderer.add('CellTag', {
|
||||||
|
renderTableDefault({ options, props }, { column, row }) {
|
||||||
|
const value = get(row, column.field);
|
||||||
|
// const tagOptions = options ?? [
|
||||||
|
// { color: 'success', label: $t('common.enabled'), value: 1 },
|
||||||
|
// { color: 'error', label: $t('common.disabled'), value: 0 },
|
||||||
|
// ];
|
||||||
|
const tagOptions = options ?? getDictOptions(DictEnum.SYS_STATUS);
|
||||||
|
const tagItem = tagOptions.find((item) => item.value === value);
|
||||||
|
return h(
|
||||||
|
Tag,
|
||||||
|
{
|
||||||
|
...props,
|
||||||
|
...objectOmit(tagItem ?? {}, ['label']),
|
||||||
|
},
|
||||||
|
{ default: () => tagItem?.label ?? value },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 单元格渲染器:Switch
|
||||||
|
vxeUI.renderer.add('CellSwitch', {
|
||||||
|
renderTableDefault({ attrs, props }, { column, row }) {
|
||||||
|
const loadingKey = `__loading_${column.field}`;
|
||||||
|
const finallyProps = {
|
||||||
|
checkedChildren: $t('common.enabled'),
|
||||||
|
checkedValue: props?.checkedValue || 1,
|
||||||
|
unCheckedChildren: $t('common.disabled'),
|
||||||
|
unCheckedValue: props?.unCheckedValue || 0,
|
||||||
|
...props,
|
||||||
|
checked: row[column.field],
|
||||||
|
loading: row[loadingKey] ?? false,
|
||||||
|
'onUpdate:checked': onChange,
|
||||||
|
};
|
||||||
|
async function onChange(newVal: any) {
|
||||||
|
row[loadingKey] = true;
|
||||||
|
try {
|
||||||
|
const result = await attrs?.beforeChange?.(newVal, row);
|
||||||
|
if (result !== false) {
|
||||||
|
row[column.field] = newVal;
|
||||||
|
attrs?.onChange?.({ row });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
row[loadingKey] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h(Switch, finallyProps);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册表格的操作按钮渲染器
|
||||||
|
vxeUI.renderer.add('CellOperation', {
|
||||||
|
renderTableDefault({ attrs, options, props }, { column, row }) {
|
||||||
|
const defaultProps = { size: 'small', type: 'link', ...props };
|
||||||
|
let align = 'end';
|
||||||
|
if (column.align === 'center') {
|
||||||
|
align = 'center';
|
||||||
|
} else if (column.align === 'left') {
|
||||||
|
align = 'start';
|
||||||
|
}
|
||||||
|
const presets: Recordable<Recordable<any>> = {
|
||||||
|
delete: {
|
||||||
|
danger: true,
|
||||||
|
text: $t('common.delete'),
|
||||||
|
},
|
||||||
|
edit: {
|
||||||
|
text: $t('common.edit'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const operations: Array<Recordable<any>> = (
|
||||||
|
options || ['edit', 'delete']
|
||||||
|
)
|
||||||
|
.map((opt) => {
|
||||||
|
if (isString(opt)) {
|
||||||
|
return presets[opt]
|
||||||
|
? { code: opt, ...presets[opt], ...defaultProps }
|
||||||
|
: {
|
||||||
|
code: opt,
|
||||||
|
text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
|
||||||
|
...defaultProps,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return { ...defaultProps, ...presets[opt.code], ...opt };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map((opt) => {
|
||||||
|
const optBtn: Recordable<any> = {};
|
||||||
|
Object.keys(opt).forEach((key) => {
|
||||||
|
optBtn[key] = isFunction(opt[key]) ? opt[key](row) : opt[key];
|
||||||
|
});
|
||||||
|
return optBtn;
|
||||||
|
})
|
||||||
|
.filter((opt) => opt.show !== false);
|
||||||
|
|
||||||
|
function renderBtn(opt: Recordable<any>, listen = true) {
|
||||||
|
return h(
|
||||||
|
Button,
|
||||||
|
{
|
||||||
|
...props,
|
||||||
|
...opt,
|
||||||
|
icon: undefined,
|
||||||
|
onClick: listen
|
||||||
|
? () =>
|
||||||
|
attrs?.onClick?.({
|
||||||
|
code: opt.code,
|
||||||
|
row,
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () => {
|
||||||
|
const content = [];
|
||||||
|
if (opt.icon) {
|
||||||
|
content.push(
|
||||||
|
h(IconifyIcon, { class: 'size-5', icon: opt.icon }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
content.push(opt.text);
|
||||||
|
return content;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConfirm(opt: Recordable<any>) {
|
||||||
|
return h(
|
||||||
|
Popconfirm,
|
||||||
|
{
|
||||||
|
getPopupContainer(el) {
|
||||||
|
if (el.closest('.fixed-right--wrapper')) {
|
||||||
|
return document.body;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
el
|
||||||
|
.closest('.vxe-table--viewport-wrapper')
|
||||||
|
?.querySelector('.vxe-table--main-wrapper')
|
||||||
|
?.querySelector('tbody') || document.body
|
||||||
|
);
|
||||||
|
},
|
||||||
|
placement: 'topLeft',
|
||||||
|
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
|
||||||
|
...props,
|
||||||
|
...opt,
|
||||||
|
icon: undefined,
|
||||||
|
onConfirm: () => {
|
||||||
|
attrs?.onClick?.({
|
||||||
|
code: opt.code,
|
||||||
|
row,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () => renderBtn({ ...opt }, false),
|
||||||
|
description: () =>
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'truncate' },
|
||||||
|
$t('ui.actionMessage.deleteConfirm', [
|
||||||
|
row[attrs?.nameField || 'name'],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDropdown(opt: Recordable<any>) {
|
||||||
|
const menuItems =
|
||||||
|
opt.items?.map((item: Recordable<any>) => ({
|
||||||
|
key: item.code || item.text,
|
||||||
|
label: item.text,
|
||||||
|
icon: item.icon ?? undefined,
|
||||||
|
disabled: item.disabled ?? undefined,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
return h(
|
||||||
|
Dropdown,
|
||||||
|
{
|
||||||
|
getPopupContainer(el) {
|
||||||
|
if (el.closest('.fixed-right--wrapper')) {
|
||||||
|
return document.body;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
el
|
||||||
|
.closest('.vxe-table--viewport-wrapper')
|
||||||
|
?.querySelector('.vxe-table--main-wrapper')
|
||||||
|
?.querySelector('tbody') || document.body
|
||||||
|
);
|
||||||
|
},
|
||||||
|
placement: 'bottomLeft',
|
||||||
|
...props,
|
||||||
|
...opt,
|
||||||
|
menu: {
|
||||||
|
items: menuItems,
|
||||||
|
onClick: ({ key }: { key: string }) =>
|
||||||
|
attrs?.onClick?.({ code: key, row }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () => renderBtn({ ...opt, icon: 'tabler:dots' }, false),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const btns = operations.map((opt) => {
|
||||||
|
if (opt.code === 'delete') {
|
||||||
|
return renderConfirm(opt);
|
||||||
|
} else if (opt.code === 'more') {
|
||||||
|
return renderDropdown(opt);
|
||||||
|
} else {
|
||||||
|
return renderBtn(opt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'flex table-operations',
|
||||||
|
style: { justifyContent: align },
|
||||||
|
},
|
||||||
|
btns,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
|
||||||
|
// vxeUI.formats.add
|
||||||
|
},
|
||||||
|
useVbenForm,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useVbenVxeGrid = <T extends Record<string, any>>(
|
||||||
|
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
|
||||||
|
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
|
||||||
|
|
||||||
|
export type OnActionClickParams<T = Recordable<any>> = {
|
||||||
|
code: string;
|
||||||
|
row: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnActionClickFn<T = Recordable<any>> = (
|
||||||
|
params: OnActionClickParams<T>,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export type * from '@vben/plugins/vxe-table';
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { miniRequestClient, requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
export interface CaptchaResult {
|
||||||
|
is_enabled: boolean;
|
||||||
|
expire_seconds: number;
|
||||||
|
uuid: string;
|
||||||
|
image: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginParams {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
uuid: string;
|
||||||
|
captcha: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResult {
|
||||||
|
access_token: string;
|
||||||
|
session_uuid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RefreshTokenResult = LoginResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录验证码
|
||||||
|
*/
|
||||||
|
export async function getCaptchaApi() {
|
||||||
|
return requestClient.get<CaptchaResult>('/api/v1/auth/captcha');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录
|
||||||
|
*/
|
||||||
|
export async function loginApi(data: LoginParams) {
|
||||||
|
return requestClient.post<LoginResult>('/api/v1/auth/login', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新accessToken
|
||||||
|
*/
|
||||||
|
export async function refreshTokenApi() {
|
||||||
|
return miniRequestClient.post<RefreshTokenResult>(
|
||||||
|
'/api/v1/auth/refresh',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出登录
|
||||||
|
*/
|
||||||
|
export async function logoutApi() {
|
||||||
|
return requestClient.post('/api/v1/auth/logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户权限码
|
||||||
|
*/
|
||||||
|
export async function getAccessCodesApi() {
|
||||||
|
return requestClient.get<string[]>('/api/v1/auth/codes');
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './auth';
|
||||||
|
export * from './menu';
|
||||||
|
export * from './upload';
|
||||||
|
export * from './user';
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { RouteRecordStringComponent } from '@vben/types';
|
||||||
|
|
||||||
|
import { $t } from '@vben/locales';
|
||||||
|
|
||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
export interface SysMenuResult {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
sort: number;
|
||||||
|
icon?: string;
|
||||||
|
type: number;
|
||||||
|
component?: string;
|
||||||
|
perms?: string;
|
||||||
|
status: number;
|
||||||
|
display: number;
|
||||||
|
cache: number;
|
||||||
|
remark?: string;
|
||||||
|
parent_id?: number;
|
||||||
|
created_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysMenuTreeResult extends SysMenuResult {
|
||||||
|
children?: SysMenuTreeResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysMenuParams {
|
||||||
|
title: string;
|
||||||
|
name: string;
|
||||||
|
path?: string;
|
||||||
|
parent_id?: number;
|
||||||
|
sort?: number;
|
||||||
|
icon?: string;
|
||||||
|
type?: number;
|
||||||
|
component?: string;
|
||||||
|
perms?: string;
|
||||||
|
status?: number;
|
||||||
|
display?: number;
|
||||||
|
cache?: number;
|
||||||
|
link?: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysMenuTreeParams {
|
||||||
|
title?: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户所有菜单
|
||||||
|
*/
|
||||||
|
export async function getAllMenusApi() {
|
||||||
|
return requestClient.get<RouteRecordStringComponent[]>(
|
||||||
|
'/api/v1/sys/menus/sidebar',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSysMenuTreeApi(params: SysMenuTreeParams) {
|
||||||
|
const transformMenuTitles = (menuData: SysMenuTreeResult[]) => {
|
||||||
|
return menuData.map((item) => {
|
||||||
|
const transformedItem = {
|
||||||
|
...item,
|
||||||
|
title: $t(item.title),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
transformedItem.children = transformMenuTitles(item.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformedItem;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterMenuTree = (
|
||||||
|
menuData: SysMenuTreeResult[],
|
||||||
|
keyword: string,
|
||||||
|
): SysMenuTreeResult[] => {
|
||||||
|
const lowerKeyword = keyword.toLowerCase();
|
||||||
|
const result: SysMenuTreeResult[] = [];
|
||||||
|
for (const item of menuData) {
|
||||||
|
const filteredChildren = item.children?.length
|
||||||
|
? filterMenuTree(item.children, keyword)
|
||||||
|
: [];
|
||||||
|
const isMatch = item.title.toLowerCase().includes(lowerKeyword);
|
||||||
|
if (isMatch || filteredChildren.length > 0) {
|
||||||
|
result.push({
|
||||||
|
...item,
|
||||||
|
children: isMatch ? item.children : filteredChildren,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { title, ...restParams } = params;
|
||||||
|
|
||||||
|
const data = await requestClient.get<SysMenuTreeResult[]>(
|
||||||
|
'/api/v1/sys/menus',
|
||||||
|
{
|
||||||
|
params: restParams,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const translatedData = transformMenuTitles(data);
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
return filterMenuTree(translatedData, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
return translatedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSysMenuApi(data: SysMenuParams) {
|
||||||
|
return requestClient.post('/api/v1/sys/menus', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSysMenuApi(pk: number, data: SysMenuParams) {
|
||||||
|
return requestClient.put(`/api/v1/sys/menus/${pk}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSysMenuApi(pk: number) {
|
||||||
|
return requestClient.delete(`/api/v1/sys/menus/${pk}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
interface UploadFileParams {
|
||||||
|
file: File;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
onProgress?: (progress: { percent: number }) => void;
|
||||||
|
onSuccess?: (data: any, file: File) => void;
|
||||||
|
}
|
||||||
|
export async function upload_file({
|
||||||
|
file,
|
||||||
|
onError,
|
||||||
|
onProgress,
|
||||||
|
onSuccess,
|
||||||
|
}: UploadFileParams) {
|
||||||
|
try {
|
||||||
|
onProgress?.({ percent: 0 });
|
||||||
|
|
||||||
|
const data = await requestClient.upload('/api/v1/sys/files/upload', {
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
onProgress?.({ percent: 100 });
|
||||||
|
onSuccess?.(data, file);
|
||||||
|
} catch (error) {
|
||||||
|
onError?.(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { UserInfo } from '@vben/types';
|
||||||
|
|
||||||
|
import type { SysDeptResult, SysRoleResult } from '#/api';
|
||||||
|
|
||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
export interface MyUserInfo extends UserInfo {
|
||||||
|
id: number;
|
||||||
|
nickname: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
dept?: string;
|
||||||
|
last_login_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysUserResult {
|
||||||
|
id: number;
|
||||||
|
uuid: string;
|
||||||
|
dept_id?: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
avatar?: string;
|
||||||
|
status: number;
|
||||||
|
is_superuser: boolean;
|
||||||
|
is_staff: boolean;
|
||||||
|
is_multi_login: boolean;
|
||||||
|
join_time: string;
|
||||||
|
last_login_time: string;
|
||||||
|
dept?: SysDeptResult;
|
||||||
|
roles: SysRoleResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysUserParams {
|
||||||
|
dept?: number;
|
||||||
|
username?: string;
|
||||||
|
phone?: string;
|
||||||
|
status?: number;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysUpdateUserParams {
|
||||||
|
dept_id?: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
roles: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysAddUserParams extends SysUpdateUserParams {
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysUpdatePasswordParams {
|
||||||
|
old_password: string;
|
||||||
|
new_password: string;
|
||||||
|
confirm_password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysUpdateUserPhoneParams {
|
||||||
|
phone: string;
|
||||||
|
captcha: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysUpdateUserEmailParams {
|
||||||
|
email: string;
|
||||||
|
captcha: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysUpdateUserNicknameParams {
|
||||||
|
nickname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysUpdateUserAvatarParams {
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysResetPasswordParams {
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户信息
|
||||||
|
*/
|
||||||
|
export async function getUserInfoApi() {
|
||||||
|
return requestClient.get<MyUserInfo>('/api/v1/sys/users/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSysUserListApi(params: SysUserParams) {
|
||||||
|
return requestClient.get<SysUserResult>('/api/v1/sys/users', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSysUserApi(data: SysAddUserParams) {
|
||||||
|
return requestClient.post('/api/v1/sys/users', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSysUserApi(pk: number, data: SysUpdateUserParams) {
|
||||||
|
return requestClient.put(`/api/v1/sys/users/${pk}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSysUserPermissionApi(pk: number, type: string) {
|
||||||
|
return requestClient.put(`/api/v1/sys/users/${pk}/permissions`, undefined, {
|
||||||
|
params: { type },
|
||||||
|
paramsSerializer: 'repeat',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSysUserAvatarApi(data: SysUpdateUserAvatarParams) {
|
||||||
|
return requestClient.put(`/api/v1/sys/users/me/avatar`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSysUserNicknameApi(
|
||||||
|
data: SysUpdateUserNicknameParams,
|
||||||
|
) {
|
||||||
|
return requestClient.put(`/api/v1/sys/users/me/nickname`, data);
|
||||||
|
}
|
||||||
|
export async function updateSysUserPhoneApi(data: SysUpdateUserPhoneParams) {
|
||||||
|
return requestClient.put(`/api/v1/sys/users/me/phone`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSysUserEmailApi(data: SysUpdateUserEmailParams) {
|
||||||
|
return requestClient.put(`/api/v1/sys/users/me/email`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSysUserPasswordApi(data: SysUpdatePasswordParams) {
|
||||||
|
return requestClient.put(`/api/v1/sys/users/me/password`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetSysUserPasswordApi(
|
||||||
|
pk: number,
|
||||||
|
data: SysResetPasswordParams,
|
||||||
|
) {
|
||||||
|
return requestClient.put(`/api/v1/sys/users/${pk}/password`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSysUserApi(pk: number) {
|
||||||
|
return requestClient.delete(`/api/v1/sys/users/${pk}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
export interface SysDataScopeResult {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
created_time: string;
|
||||||
|
updated_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysDataRuleResult {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
model: string;
|
||||||
|
column: string;
|
||||||
|
operator: string;
|
||||||
|
expression: string;
|
||||||
|
value: string;
|
||||||
|
created_time: string;
|
||||||
|
updated_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysDataScopeParams {
|
||||||
|
name?: string;
|
||||||
|
status?: number;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSysDataScopeParams {
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysDataScopeRulesResult extends SysDataScopeResult {
|
||||||
|
rules: SysDataRuleResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysDataRuleParams {
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysDataRuleModelColumnsResult {
|
||||||
|
key: string;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysDataRuleTemplateVariableResult {
|
||||||
|
key: string;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSysDataRuleParams {
|
||||||
|
name: string;
|
||||||
|
model: string;
|
||||||
|
column: string;
|
||||||
|
operator: string;
|
||||||
|
expression: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSysDataScopeListApi(params: SysDataScopeParams) {
|
||||||
|
return requestClient.get<SysDataScopeResult[]>('/api/v1/sys/data-scopes', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSysDataScopesApi() {
|
||||||
|
return requestClient.get<SysDataScopeResult[]>('/api/v1/sys/data-scopes/all');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSysDataScopeRulesApi(pk: number) {
|
||||||
|
return requestClient.get<SysDataScopeRulesResult>(
|
||||||
|
`/api/v1/sys/data-scopes/${pk}/rules`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSysDataScope(data: CreateSysDataScopeParams) {
|
||||||
|
return requestClient.post('/api/v1/sys/data-scopes', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSysDataScope(
|
||||||
|
pk: number,
|
||||||
|
data: CreateSysDataScopeParams,
|
||||||
|
) {
|
||||||
|
return requestClient.put(`/api/v1/sys/data-scopes/${pk}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSysDataScopeRulesApi(pk: number, rules: number[]) {
|
||||||
|
return requestClient.put(`/api/v1/sys/data-scopes/${pk}/rules`, { rules });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSysDataScopeApi(pks: number[]) {
|
||||||
|
return requestClient.delete(`/api/v1/sys/data-scopes`, { data: { pks } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSysDataRuleListApi(params: SysDataRuleParams) {
|
||||||
|
return requestClient.get<SysDataRuleResult[]>('/api/v1/sys/data-rules', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSysDataRulesApi() {
|
||||||
|
return requestClient.get<SysDataRuleResult[]>('/api/v1/sys/data-rules/all');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSysDataRuleModelsApi() {
|
||||||
|
return requestClient.get<string[]>('/api/v1/sys/data-rules/models');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSysDataRuleModelColumnsApi(model: string) {
|
||||||
|
return requestClient.get<SysDataRuleModelColumnsResult[]>(
|
||||||
|
`/api/v1/sys/data-rules/models/${model}/columns`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSysDataRuleTemplateVariablesApi() {
|
||||||
|
return requestClient.get<SysDataRuleTemplateVariableResult[]>(
|
||||||
|
'/api/v1/sys/data-rules/value-template-variables',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSysDataRuleApi(data: CreateSysDataRuleParams) {
|
||||||
|
return requestClient.post('/api/v1/sys/data-rules', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSysDataRuleApi(
|
||||||
|
pk: number,
|
||||||
|
data: CreateSysDataRuleParams,
|
||||||
|
) {
|
||||||
|
return requestClient.put(`/api/v1/sys/data-rules/${pk}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSysDataRuleApi(pks: number[]) {
|
||||||
|
return requestClient.delete(`/api/v1/sys/data-rules`, { data: { pks } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { requestClient } from './request';
|
||||||
|
|
||||||
|
export interface SysDeptResult {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
parent_id: number;
|
||||||
|
sort: number;
|
||||||
|
leader?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
status: number;
|
||||||
|
created_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysDeptTreeResult extends SysDeptResult {
|
||||||
|
children?: SysDeptTreeResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysDeptParams {
|
||||||
|
name: string;
|
||||||
|
parent_id?: number;
|
||||||
|
sort?: number;
|
||||||
|
leader?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysDeptTreeParams {
|
||||||
|
name?: string;
|
||||||
|
leader?: string;
|
||||||
|
phone?: string;
|
||||||
|
status?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取部门树
|
||||||
|
*/
|
||||||
|
export async function getSysDeptTreeApi(params: SysDeptTreeParams) {
|
||||||
|
return requestClient.get<SysDeptTreeResult[]>('/api/v1/sys/depts', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取部门详情
|
||||||
|
*/
|
||||||
|
export async function getSysDeptDetailApi(pk: number) {
|
||||||
|
return requestClient.get<SysDeptTreeResult>(`/api/v1/sys/depts/${pk}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建部门
|
||||||
|
*/
|
||||||
|
export async function createSysDeptApi(data: SysDeptParams) {
|
||||||
|
return requestClient.post('/api/v1/sys/depts', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新部门
|
||||||
|
*/
|
||||||
|
export async function updateSysDeptApi(pk: number, data: SysDeptParams) {
|
||||||
|
return requestClient.put(`/api/v1/sys/depts/${pk}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除部门
|
||||||
|
*/
|
||||||
|
export async function deleteSysDeptApi(pk: number) {
|
||||||
|
return requestClient.delete(`/api/v1/sys/depts/${pk}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export * from './core';
|
||||||
|
export * from './data-permission';
|
||||||
|
export * from './dept';
|
||||||
|
export * from './log';
|
||||||
|
export * from './monitor';
|
||||||
|
export * from './plugin';
|
||||||
|
export * from './role';
|
||||||
|
export * from './scheduler';
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { PaginationResult } from '#/types';
|
||||||
|
|
||||||
|
import { requestClient } from './request';
|
||||||
|
|
||||||
|
export interface LoginLogParams {
|
||||||
|
username?: string;
|
||||||
|
status?: number;
|
||||||
|
ip?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginLogResult {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
status: number;
|
||||||
|
ip: string;
|
||||||
|
country?: string;
|
||||||
|
region?: string;
|
||||||
|
os?: string;
|
||||||
|
browser?: string;
|
||||||
|
device?: string;
|
||||||
|
msg: string;
|
||||||
|
login_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OperaLogParams = LoginLogParams;
|
||||||
|
|
||||||
|
export interface OperaLogResult {
|
||||||
|
id: number;
|
||||||
|
trace_id: string;
|
||||||
|
username?: string;
|
||||||
|
method: string;
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
ip: string;
|
||||||
|
country?: string;
|
||||||
|
region?: string;
|
||||||
|
city?: string;
|
||||||
|
user_agent: string;
|
||||||
|
os?: string;
|
||||||
|
browser?: string;
|
||||||
|
device?: string;
|
||||||
|
args?: JSON;
|
||||||
|
status: number;
|
||||||
|
code: string;
|
||||||
|
msg: string;
|
||||||
|
cost_time: number;
|
||||||
|
opera_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLoginLogListApi(params: LoginLogParams) {
|
||||||
|
return requestClient.get<PaginationResult<LoginLogResult>>(
|
||||||
|
'/api/v1/logs/login',
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLoginLogApi(pks: number[]) {
|
||||||
|
return requestClient.delete('/api/v1/logs/login', { data: { pks } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOperaLogListApi(params: OperaLogParams) {
|
||||||
|
return requestClient.get<PaginationResult<OperaLogResult>>(
|
||||||
|
'/api/v1/logs/opera',
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOperaLogApi(pks: number[]) {
|
||||||
|
return requestClient.delete('/api/v1/logs/opera', { data: { pks } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { requestClient } from './request';
|
||||||
|
|
||||||
|
export interface ServerMonitorResult {
|
||||||
|
cpu: Record<string, any>;
|
||||||
|
mem: Record<string, any>;
|
||||||
|
sys: Record<string, any>;
|
||||||
|
disk: Record<string, any>[];
|
||||||
|
service: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedisMonitorResult {
|
||||||
|
info: Record<string, any>;
|
||||||
|
stats: Record<string, any>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineMonitorResult {
|
||||||
|
id: number;
|
||||||
|
session_uuid: string;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
ip: string;
|
||||||
|
os: string;
|
||||||
|
browser: string;
|
||||||
|
device: string;
|
||||||
|
status: number;
|
||||||
|
last_login_time: string;
|
||||||
|
expires_time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorOnlineParams {
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KickOutOnlineParams {
|
||||||
|
session_uuid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerMonitorApi() {
|
||||||
|
return requestClient.get<ServerMonitorResult>('/api/v1/monitors/server');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRedisMonitorApi() {
|
||||||
|
return requestClient.get<RedisMonitorResult>('/api/v1/monitors/redis');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOnlineMonitorApi(params: MonitorOnlineParams) {
|
||||||
|
return requestClient.get<OnlineMonitorResult[]>('/api/v1/monitors/sessions', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kickOutOnlineApi(
|
||||||
|
pk: number,
|
||||||
|
params: KickOutOnlineParams,
|
||||||
|
) {
|
||||||
|
return requestClient.delete(`/api/v1/monitors/sessions/${pk}`, { params });
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
export interface PluginResult {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPluginListApi() {
|
||||||
|
return requestClient.get<PluginResult[]>('/api/v1/sys/plugins');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPluginChangedApi() {
|
||||||
|
return requestClient.get<boolean>('/api/v1/sys/plugins/changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installZipPluginApi(file: File) {
|
||||||
|
return await requestClient.upload(
|
||||||
|
'/api/v1/sys/plugins',
|
||||||
|
{ file },
|
||||||
|
{ params: { type: 'zip' }, timeout: 60_000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installGitPluginApi(repo_url: string) {
|
||||||
|
return await requestClient.post('/api/v1/sys/plugins', undefined, {
|
||||||
|
params: { type: 'git', repo_url },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePluginStatus(plugin: string) {
|
||||||
|
return await requestClient.put(`/api/v1/sys/plugins/${plugin}/status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadPluginApi(plugin: string) {
|
||||||
|
return await requestClient.download<Blob>(`/api/v1/sys/plugins/${plugin}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uninstallPluginApi(plugin: string) {
|
||||||
|
return await requestClient.delete(`/api/v1/sys/plugins/${plugin}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* 该文件可自行根据业务逻辑进行调整
|
||||||
|
*/
|
||||||
|
import type { RequestClientOptions } from '@vben/request';
|
||||||
|
|
||||||
|
import { useAppConfig } from '@vben/hooks';
|
||||||
|
import { preferences } from '@vben/preferences';
|
||||||
|
import {
|
||||||
|
authenticateResponseInterceptor,
|
||||||
|
defaultResponseInterceptor,
|
||||||
|
errorMessageResponseInterceptor,
|
||||||
|
RequestClient,
|
||||||
|
} from '@vben/request';
|
||||||
|
import { useAccessStore } from '@vben/stores';
|
||||||
|
|
||||||
|
import { message } from 'antdv-next';
|
||||||
|
|
||||||
|
import { useAuthStore } from '#/store';
|
||||||
|
|
||||||
|
import { refreshTokenApi } from './core';
|
||||||
|
|
||||||
|
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||||
|
|
||||||
|
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||||
|
const client = new RequestClient({
|
||||||
|
...options,
|
||||||
|
baseURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新认证逻辑
|
||||||
|
*/
|
||||||
|
async function doReAuthenticate() {
|
||||||
|
console.warn('Access token or refresh token is invalid or expired. ');
|
||||||
|
const accessStore = useAccessStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
accessStore.setAccessToken(null);
|
||||||
|
accessStore.setAccessSessionUuid(null);
|
||||||
|
if (
|
||||||
|
preferences.app.loginExpiredMode === 'modal' &&
|
||||||
|
accessStore.isAccessChecked
|
||||||
|
) {
|
||||||
|
accessStore.setLoginExpired(true);
|
||||||
|
} else {
|
||||||
|
await authStore.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新token逻辑
|
||||||
|
*/
|
||||||
|
async function doRefreshToken() {
|
||||||
|
const accessStore = useAccessStore();
|
||||||
|
const resp = await refreshTokenApi();
|
||||||
|
const newToken = resp.access_token;
|
||||||
|
accessStore.setAccessToken(newToken);
|
||||||
|
accessStore.setAccessSessionUuid(resp.session_uuid);
|
||||||
|
return newToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToken(token: null | string) {
|
||||||
|
return token ? `Bearer ${token}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求头处理
|
||||||
|
client.addRequestInterceptor({
|
||||||
|
fulfilled: async (config) => {
|
||||||
|
const accessStore = useAccessStore();
|
||||||
|
|
||||||
|
config.headers.Authorization = formatToken(accessStore.accessToken);
|
||||||
|
config.headers['Accept-Language'] = preferences.app.locale;
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理返回的响应数据格式
|
||||||
|
client.addResponseInterceptor(
|
||||||
|
defaultResponseInterceptor({
|
||||||
|
codeField: 'code',
|
||||||
|
dataField: 'data',
|
||||||
|
successCode: 200,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// token过期的处理
|
||||||
|
client.addResponseInterceptor(
|
||||||
|
authenticateResponseInterceptor({
|
||||||
|
client,
|
||||||
|
doReAuthenticate,
|
||||||
|
doRefreshToken,
|
||||||
|
enableRefreshToken: preferences.app.enableRefreshToken,
|
||||||
|
formatToken,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
|
||||||
|
client.addResponseInterceptor(
|
||||||
|
errorMessageResponseInterceptor((msg: string, error) => {
|
||||||
|
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
|
||||||
|
// 当前mock接口返回的错误字段是 error 或者 message
|
||||||
|
const responseData = error?.response?.data ?? {};
|
||||||
|
const errorMessage =
|
||||||
|
responseData?.error ?? responseData?.msg ?? error?.msg ?? '';
|
||||||
|
// 如果没有错误信息,则会根据状态码进行提示
|
||||||
|
message.error(errorMessage || msg);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMiniRequestClient(
|
||||||
|
baseURL: string,
|
||||||
|
options?: RequestClientOptions,
|
||||||
|
) {
|
||||||
|
const client = new RequestClient({
|
||||||
|
...options,
|
||||||
|
baseURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理返回的响应数据格式
|
||||||
|
client.addResponseInterceptor(
|
||||||
|
defaultResponseInterceptor({
|
||||||
|
codeField: 'code',
|
||||||
|
dataField: 'data',
|
||||||
|
successCode: 200,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 通用的错误处理
|
||||||
|
client.addResponseInterceptor(
|
||||||
|
errorMessageResponseInterceptor((msg: string, error) => {
|
||||||
|
const responseData = error?.response?.data ?? {};
|
||||||
|
const errorMessage =
|
||||||
|
responseData?.error ?? responseData?.msg ?? error?.msg ?? '';
|
||||||
|
message.error(errorMessage || msg);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestClient = createRequestClient(apiURL, {
|
||||||
|
responseReturn: 'data',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const miniRequestClient = createMiniRequestClient(apiURL, {
|
||||||
|
responseReturn: 'data',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { RouteRecordStringComponent } from '@vben/types';
|
||||||
|
|
||||||
|
import { requestClient } from './request';
|
||||||
|
|
||||||
|
export interface SysRoleParams {
|
||||||
|
name?: string;
|
||||||
|
status?: number;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysRoleResult {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
is_filter_scopes: boolean;
|
||||||
|
remark?: string;
|
||||||
|
created_time: string;
|
||||||
|
updated_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSysRoleParams {
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取系统角色列表
|
||||||
|
*/
|
||||||
|
export async function getSysRoleListApi(params: SysRoleParams) {
|
||||||
|
return requestClient.get<SysRoleResult[]>('/api/v1/sys/roles', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllSysRoleApi() {
|
||||||
|
return requestClient.get<SysRoleResult[]>('/api/v1/sys/roles/all');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSysRoleMenuApi(pk: number) {
|
||||||
|
return requestClient.get<RouteRecordStringComponent[]>(
|
||||||
|
`/api/v1/sys/roles/${pk}/menus`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSysRoleDataScopesApi(pk: number) {
|
||||||
|
return requestClient.get<number[]>(`/api/v1/sys/roles/${pk}/scopes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSysRoleApi(data: CreateSysRoleParams) {
|
||||||
|
return requestClient.post('/api/v1/sys/roles', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSysRoleApi(pk: number, data: CreateSysRoleParams) {
|
||||||
|
return requestClient.put(`/api/v1/sys/roles/${pk}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSysRoleMenuApi(pk: number, menus: number[]) {
|
||||||
|
return requestClient.put(`/api/v1/sys/roles/${pk}/menus`, { menus });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSysRoleDataScopesApi(pk: number, scopes: number[]) {
|
||||||
|
return requestClient.put(`/api/v1/sys/roles/${pk}/scopes`, { scopes });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSysRoleApi(pks: number[]) {
|
||||||
|
return requestClient.delete(`/api/v1/sys/roles`, { data: { pks } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
import type { PaginationResult } from '#/types';
|
||||||
|
|
||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
export interface TaskResultParams {
|
||||||
|
name?: string;
|
||||||
|
task_id?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskSchedulerParams {
|
||||||
|
name?: string;
|
||||||
|
type?: number;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskResult {
|
||||||
|
id: number;
|
||||||
|
task_id: string;
|
||||||
|
status: string;
|
||||||
|
result?: string;
|
||||||
|
date_done?: string;
|
||||||
|
traceback?: string;
|
||||||
|
name?: string;
|
||||||
|
args?: string;
|
||||||
|
kwargs?: string;
|
||||||
|
worker?: string;
|
||||||
|
retries?: number;
|
||||||
|
queue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskSchedulerParams {
|
||||||
|
name: string;
|
||||||
|
task: string;
|
||||||
|
args?: string;
|
||||||
|
kwargs?: string;
|
||||||
|
queue?: string;
|
||||||
|
exchange?: string;
|
||||||
|
routing_key?: string;
|
||||||
|
start_time?: Dayjs;
|
||||||
|
expire_time?: Dayjs;
|
||||||
|
expire_seconds?: number;
|
||||||
|
type: number;
|
||||||
|
interval_every?: number;
|
||||||
|
interval_period?: string;
|
||||||
|
crontab: string;
|
||||||
|
one_off: boolean;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskSchedulerResult extends CreateTaskSchedulerParams {
|
||||||
|
id: number;
|
||||||
|
enabled: boolean;
|
||||||
|
total_run_count: number;
|
||||||
|
last_run_time: string;
|
||||||
|
created_time: string;
|
||||||
|
updated_time?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskResultApi(pk: number) {
|
||||||
|
return requestClient.get<TaskResult>(`/api/v1/task-results/${pk}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskResultListApi(params?: TaskResultParams) {
|
||||||
|
return requestClient.get<PaginationResult<TaskSchedulerResult>>(
|
||||||
|
'/api/v1/task-results',
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTaskResultApi(pks: number[]) {
|
||||||
|
return requestClient.delete('/api/v1/task-results', { data: { pks } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllTaskSchedulerApi() {
|
||||||
|
return requestClient.get<TaskSchedulerResult[]>('/api/v1/schedulers/all');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskSchedulerListApi(params?: TaskSchedulerParams) {
|
||||||
|
return requestClient.get<PaginationResult<TaskSchedulerResult>>(
|
||||||
|
'/api/v1/schedulers',
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskSchedulerApi(pk: number) {
|
||||||
|
return requestClient.get<TaskSchedulerResult>(`/api/v1/schedulers/${pk}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTaskSchedulerApi(data: CreateTaskSchedulerParams) {
|
||||||
|
return requestClient.post('/api/v1/schedulers', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTaskSchedulerApi(
|
||||||
|
pk: number,
|
||||||
|
data: CreateTaskSchedulerParams,
|
||||||
|
) {
|
||||||
|
return requestClient.put(`/api/v1/schedulers/${pk}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTaskSchedulerStatusApi(pk: number) {
|
||||||
|
return requestClient.put(`/api/v1/schedulers/${pk}/status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTaskSchedulerApi(pk: number) {
|
||||||
|
return requestClient.delete(`/api/v1/schedulers/${pk}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeTaskSchedulerApi(pk: number) {
|
||||||
|
return requestClient.post(`/api/v1/schedulers/${pk}/execute`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskRegisteredApi() {
|
||||||
|
return requestClient.get<any[]>('/api/v1/tasks/registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeTaskSchedulerApi(task_id: string) {
|
||||||
|
return requestClient.delete(`/api/v1/tasks/${task_id}/cancel`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
|
||||||
|
import { useAntdDesignTokens } from '@vben/hooks';
|
||||||
|
import { preferences, usePreferences } from '@vben/preferences';
|
||||||
|
|
||||||
|
import { App, ConfigProvider, theme } from 'antdv-next';
|
||||||
|
|
||||||
|
import { antdLocale } from '#/locales';
|
||||||
|
|
||||||
|
defineOptions({ name: 'App' });
|
||||||
|
|
||||||
|
const { isDark } = usePreferences();
|
||||||
|
const { tokens } = useAntdDesignTokens();
|
||||||
|
|
||||||
|
const tokenTheme = computed(() => {
|
||||||
|
const algorithm = isDark.value
|
||||||
|
? [theme.darkAlgorithm]
|
||||||
|
: [theme.defaultAlgorithm];
|
||||||
|
|
||||||
|
// antd 紧凑模式算法
|
||||||
|
if (preferences.app.compact) {
|
||||||
|
algorithm.push(theme.compactAlgorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
algorithm,
|
||||||
|
token: tokens,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
tokenTheme,
|
||||||
|
(themeConfig) => {
|
||||||
|
ConfigProvider.config({ theme: themeConfig });
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
|
||||||
|
<App>
|
||||||
|
<RouterView />
|
||||||
|
</App>
|
||||||
|
</ConfigProvider>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { createApp, watchEffect } from 'vue';
|
||||||
|
|
||||||
|
import { registerAccessDirective } from '@vben/access';
|
||||||
|
import { registerLoadingDirective } from '@vben/common-ui/es/loading';
|
||||||
|
import { preferences } from '@vben/preferences';
|
||||||
|
import { initStores } from '@vben/stores';
|
||||||
|
import '@vben/styles';
|
||||||
|
import '@vben/styles/antdv-next';
|
||||||
|
|
||||||
|
import { useTitle } from '@vueuse/core';
|
||||||
|
import Antd from 'antdv-next';
|
||||||
|
|
||||||
|
import { $t, setupI18n } from '#/locales';
|
||||||
|
|
||||||
|
import { initComponentAdapter } from './adapter/component';
|
||||||
|
import { initSetupVbenForm } from './adapter/form';
|
||||||
|
import App from './app.vue';
|
||||||
|
import { router } from './router';
|
||||||
|
|
||||||
|
async function bootstrap(namespace: string) {
|
||||||
|
// 初始化组件适配器
|
||||||
|
await initComponentAdapter();
|
||||||
|
|
||||||
|
// 初始化表单组件
|
||||||
|
await initSetupVbenForm();
|
||||||
|
|
||||||
|
// // 设置弹窗的默认配置
|
||||||
|
// setDefaultModalProps({
|
||||||
|
// fullscreenButton: false,
|
||||||
|
// });
|
||||||
|
// // 设置抽屉的默认配置
|
||||||
|
// setDefaultDrawerProps({
|
||||||
|
// zIndex: 1020,
|
||||||
|
// });
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
// 注册v-loading指令
|
||||||
|
registerLoadingDirective(app, {
|
||||||
|
loading: 'loading', // 在这里可以自定义指令名称,也可以明确提供false表示不注册这个指令
|
||||||
|
spinning: 'spinning',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 国际化 i18n 配置
|
||||||
|
await setupI18n(app);
|
||||||
|
|
||||||
|
// 配置 pinia-tore
|
||||||
|
await initStores(app, { namespace });
|
||||||
|
|
||||||
|
// 安装权限指令
|
||||||
|
registerAccessDirective(app);
|
||||||
|
|
||||||
|
// 初始化 tippy
|
||||||
|
const { initTippy } = await import('@vben/common-ui/es/tippy');
|
||||||
|
initTippy(app);
|
||||||
|
|
||||||
|
// 全局加载 antdv
|
||||||
|
app.use(Antd);
|
||||||
|
|
||||||
|
// 配置路由及路由守卫
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
|
// 配置Motion插件
|
||||||
|
const { MotionPlugin } = await import('@vben/plugins/motion');
|
||||||
|
app.use(MotionPlugin);
|
||||||
|
|
||||||
|
// 动态更新标题
|
||||||
|
watchEffect(() => {
|
||||||
|
if (preferences.app.dynamicTitle) {
|
||||||
|
const routeTitle = router.currentRoute.value.meta?.title;
|
||||||
|
const pageTitle =
|
||||||
|
(routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name;
|
||||||
|
useTitle(pageTitle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.mount('#app');
|
||||||
|
}
|
||||||
|
|
||||||
|
export { bootstrap };
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { AuthPageLayout } from '@vben/layouts';
|
||||||
|
import { preferences } from '@vben/preferences';
|
||||||
|
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
const appName = computed(() => preferences.app.name);
|
||||||
|
const logo = computed(() => preferences.logo.source);
|
||||||
|
const logoDark = computed(() => preferences.logo.sourceDark);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuthPageLayout
|
||||||
|
:app-name="appName"
|
||||||
|
:logo="logo"
|
||||||
|
:logo-dark="logoDark"
|
||||||
|
:page-description="$t('authentication.pageDesc')"
|
||||||
|
:page-title="$t('authentication.pageTitle')"
|
||||||
|
>
|
||||||
|
<!-- 自定义工具栏 -->
|
||||||
|
<!-- <template #toolbar></template> -->
|
||||||
|
</AuthPageLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { NotificationItem } from '@vben/layouts';
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
|
||||||
|
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
|
||||||
|
import { useWatermark } from '@vben/hooks';
|
||||||
|
import { BookOpenText, CircleHelp, SvgGithubIcon } from '@vben/icons';
|
||||||
|
import {
|
||||||
|
BasicLayout,
|
||||||
|
LockScreen,
|
||||||
|
Notification,
|
||||||
|
UserDropdown,
|
||||||
|
} from '@vben/layouts';
|
||||||
|
import { preferences, usePreferences } from '@vben/preferences';
|
||||||
|
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||||
|
import { openWindow } from '@vben/utils';
|
||||||
|
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
import { useAuthStore } from '#/store';
|
||||||
|
import LoginForm from '#/views/_core/authentication/login.vue';
|
||||||
|
|
||||||
|
const notifications = ref<NotificationItem[]>([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
|
||||||
|
date: '3小时前',
|
||||||
|
isRead: true,
|
||||||
|
message: '描述信息描述信息描述信息',
|
||||||
|
title: '收到了 14 份新周报',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
avatar: 'https://avatar.vercel.sh/1',
|
||||||
|
date: '刚刚',
|
||||||
|
isRead: false,
|
||||||
|
message: '描述信息描述信息描述信息',
|
||||||
|
title: '朱偏右 回复了你',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
avatar: 'https://avatar.vercel.sh/1',
|
||||||
|
date: '2024-01-01',
|
||||||
|
isRead: false,
|
||||||
|
message: '描述信息描述信息描述信息',
|
||||||
|
title: '曲丽丽 评论了你',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
avatar: 'https://avatar.vercel.sh/satori',
|
||||||
|
date: '1天前',
|
||||||
|
isRead: false,
|
||||||
|
message: '描述信息描述信息描述信息',
|
||||||
|
title: '代办提醒',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
avatar: 'https://avatar.vercel.sh/satori',
|
||||||
|
date: '1天前',
|
||||||
|
isRead: false,
|
||||||
|
message: '描述信息描述信息描述信息',
|
||||||
|
title: '跳转Workspace示例',
|
||||||
|
link: '/workspace',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
avatar: 'https://avatar.vercel.sh/satori',
|
||||||
|
date: '1天前',
|
||||||
|
isRead: false,
|
||||||
|
message: '描述信息描述信息描述信息',
|
||||||
|
title: '跳转外部链接示例',
|
||||||
|
link: 'https://doc.vben.pro',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const accessStore = useAccessStore();
|
||||||
|
const { destroyWatermark, updateWatermark } = useWatermark();
|
||||||
|
const { isDark } = usePreferences();
|
||||||
|
const showDot = computed(() =>
|
||||||
|
notifications.value.some((item) => !item.isRead),
|
||||||
|
);
|
||||||
|
|
||||||
|
const menus = computed(() => [
|
||||||
|
{
|
||||||
|
handler: () => {
|
||||||
|
router.push({ name: 'Profile' });
|
||||||
|
},
|
||||||
|
icon: 'lucide:user',
|
||||||
|
text: $t('page.auth.profile'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
handler: () => {
|
||||||
|
openWindow(VBEN_DOC_URL, {
|
||||||
|
target: '_blank',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: BookOpenText,
|
||||||
|
text: $t('ui.widgets.document'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
handler: () => {
|
||||||
|
openWindow(VBEN_GITHUB_URL, {
|
||||||
|
target: '_blank',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: SvgGithubIcon,
|
||||||
|
text: 'GitHub',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
handler: () => {
|
||||||
|
openWindow(`${VBEN_GITHUB_URL}/issues`, {
|
||||||
|
target: '_blank',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: CircleHelp,
|
||||||
|
text: $t('ui.widgets.qa'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const avatar = computed(() => {
|
||||||
|
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await authStore.logout(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNoticeClear() {
|
||||||
|
notifications.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function markRead(id: number | string) {
|
||||||
|
const item = notifications.value.find((item) => item.id === id);
|
||||||
|
if (item) {
|
||||||
|
item.isRead = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: number | string) {
|
||||||
|
notifications.value = notifications.value.filter((item) => item.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMakeAll() {
|
||||||
|
notifications.value.forEach((item) => (item.isRead = true));
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewAll = () => {};
|
||||||
|
|
||||||
|
const handleClick = (item: NotificationItem) => {
|
||||||
|
// 如果通知项有链接,点击时跳转
|
||||||
|
if (item.link) {
|
||||||
|
navigateTo(item.link, item.query, item.state);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function navigateTo(
|
||||||
|
link: string,
|
||||||
|
query?: Record<string, any>,
|
||||||
|
state?: Record<string, any>,
|
||||||
|
) {
|
||||||
|
if (link.startsWith('http://') || link.startsWith('https://')) {
|
||||||
|
// 外部链接,在新标签页打开
|
||||||
|
window.open(link, '_blank');
|
||||||
|
} else {
|
||||||
|
// 内部路由链接,支持 query 参数和 state
|
||||||
|
router.push({
|
||||||
|
path: link,
|
||||||
|
query: query || {},
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => ({
|
||||||
|
enable: preferences.app.watermark,
|
||||||
|
content: preferences.app.watermarkContent,
|
||||||
|
isDark: isDark.value,
|
||||||
|
}),
|
||||||
|
async ({ enable, content, isDark: isDarkValue }) => {
|
||||||
|
if (enable) {
|
||||||
|
const watermarkColor = isDarkValue
|
||||||
|
? 'rgba(255, 255, 255, 0.12)'
|
||||||
|
: 'rgba(0, 0, 0, 0.12)';
|
||||||
|
|
||||||
|
await updateWatermark({
|
||||||
|
advancedStyle: {
|
||||||
|
colorStops: [
|
||||||
|
{
|
||||||
|
color: watermarkColor,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: watermarkColor,
|
||||||
|
offset: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: 'linear',
|
||||||
|
},
|
||||||
|
content:
|
||||||
|
content ||
|
||||||
|
`${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
destroyWatermark();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BasicLayout @clear-preferences-and-logout="handleLogout">
|
||||||
|
<template #user-dropdown>
|
||||||
|
<UserDropdown
|
||||||
|
:avatar
|
||||||
|
:menus
|
||||||
|
:text="userStore.userInfo?.realName"
|
||||||
|
description="ann.vben@gmail.com"
|
||||||
|
tag-text="Pro"
|
||||||
|
@logout="handleLogout"
|
||||||
|
@clear-preferences-and-logout="handleLogout"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #notification>
|
||||||
|
<Notification
|
||||||
|
:dot="showDot"
|
||||||
|
:notifications="notifications"
|
||||||
|
@clear="handleNoticeClear"
|
||||||
|
@read="(item) => item.id && markRead(item.id)"
|
||||||
|
@remove="(item) => item.id && remove(item.id)"
|
||||||
|
@make-all="handleMakeAll"
|
||||||
|
@on-click="handleClick"
|
||||||
|
@view-all="viewAll"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #extra>
|
||||||
|
<AuthenticationLoginExpiredModal
|
||||||
|
v-model:open="accessStore.loginExpired"
|
||||||
|
:avatar
|
||||||
|
>
|
||||||
|
<LoginForm />
|
||||||
|
</AuthenticationLoginExpiredModal>
|
||||||
|
</template>
|
||||||
|
<template #lock-screen>
|
||||||
|
<LockScreen :avatar @to-login="handleLogout" />
|
||||||
|
</template>
|
||||||
|
</BasicLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const BasicLayout = () => import('./basic.vue');
|
||||||
|
const AuthPageLayout = () => import('./auth.vue');
|
||||||
|
|
||||||
|
const IFrameView = () => import('@vben/layouts').then((m) => m.IFrameView);
|
||||||
|
|
||||||
|
export { AuthPageLayout, BasicLayout, IFrameView };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# locale
|
||||||
|
|
||||||
|
每个app使用的国际化可能不同,这里用于扩展国际化的功能,例如扩展 dayjs、antd组件库的多语言切换,以及app本身的国际化文件。
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { Locale } from 'antdv-next/dist/locale/index';
|
||||||
|
|
||||||
|
import type { App } from 'vue';
|
||||||
|
|
||||||
|
import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales';
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
$t,
|
||||||
|
setupI18n as coreSetup,
|
||||||
|
loadLocalesMapFromDir,
|
||||||
|
} from '@vben/locales';
|
||||||
|
import { preferences } from '@vben/preferences';
|
||||||
|
|
||||||
|
import antdEnLocale from 'antdv-next/dist/locale/en_US';
|
||||||
|
import antdDefaultLocale from 'antdv-next/dist/locale/zh_CN';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
const antdLocale = ref<Locale>(antdDefaultLocale);
|
||||||
|
|
||||||
|
const modules = import.meta.glob('./langs/**/*.json');
|
||||||
|
|
||||||
|
const pluginModules = import.meta.glob('../plugins/**/langs/**/*.json');
|
||||||
|
|
||||||
|
const localesMap = loadLocalesMapFromDir(
|
||||||
|
/\.\/langs\/([^/]+)\/(.*)\.json$/,
|
||||||
|
modules,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pluginLocalesMap = loadLocalesMapFromDir(
|
||||||
|
/\/plugins\/[^/]+\/langs\/([^/]+)\/([^/]+)\.json$/,
|
||||||
|
pluginModules,
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* 加载应用特有的语言包
|
||||||
|
* 这里也可以改造为从服务端获取翻译数据
|
||||||
|
* @param lang
|
||||||
|
*/
|
||||||
|
async function loadMessages(lang: SupportedLanguagesType) {
|
||||||
|
const [appLocaleMessages, pluginLocalMessages] = await Promise.all([
|
||||||
|
localesMap[lang]?.(),
|
||||||
|
pluginLocalesMap[lang]?.(),
|
||||||
|
loadThirdPartyMessage(lang),
|
||||||
|
]);
|
||||||
|
return { ...appLocaleMessages?.default, ...pluginLocalMessages?.default };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载第三方组件库的语言包
|
||||||
|
* @param lang
|
||||||
|
*/
|
||||||
|
async function loadThirdPartyMessage(lang: SupportedLanguagesType) {
|
||||||
|
await Promise.all([loadAntdLocale(lang), loadDayjsLocale(lang)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载dayjs的语言包
|
||||||
|
* @param lang
|
||||||
|
*/
|
||||||
|
async function loadDayjsLocale(lang: SupportedLanguagesType) {
|
||||||
|
let locale;
|
||||||
|
switch (lang) {
|
||||||
|
case 'en-US': {
|
||||||
|
locale = await import('dayjs/locale/en');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'zh-CN': {
|
||||||
|
locale = await import('dayjs/locale/zh-cn');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 默认使用英语
|
||||||
|
default: {
|
||||||
|
locale = await import('dayjs/locale/en');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (locale) {
|
||||||
|
dayjs.locale(locale);
|
||||||
|
} else {
|
||||||
|
console.error(`Failed to load dayjs locale for ${lang}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载antd的语言包
|
||||||
|
* @param lang
|
||||||
|
*/
|
||||||
|
async function loadAntdLocale(lang: SupportedLanguagesType) {
|
||||||
|
switch (lang) {
|
||||||
|
case 'en-US': {
|
||||||
|
antdLocale.value = antdEnLocale;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'zh-CN': {
|
||||||
|
antdLocale.value = antdDefaultLocale;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupI18n(app: App, options: LocaleSetupOptions = {}) {
|
||||||
|
await coreSetup(app, {
|
||||||
|
defaultLocale: preferences.app.locale,
|
||||||
|
loadMessages,
|
||||||
|
missingWarn: !import.meta.env.PROD,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { $t, antdLocale, setupI18n };
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"form": {
|
||||||
|
"query": "Query",
|
||||||
|
"select": "Please select",
|
||||||
|
"status": "Status"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"created_time": "Created Time",
|
||||||
|
"id": "ID",
|
||||||
|
"mark": "Mark",
|
||||||
|
"operation": "Operation",
|
||||||
|
"updated_time": "Updated time"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"title": "Demos",
|
||||||
|
"antd": "Antdv Next",
|
||||||
|
"vben": {
|
||||||
|
"title": "Project",
|
||||||
|
"about": "About",
|
||||||
|
"document": "Document",
|
||||||
|
"antdv": "Ant Design Vue Version",
|
||||||
|
"antdv-next": "Antdv Next Version",
|
||||||
|
"naive-ui": "Naive UI Version",
|
||||||
|
"element-plus": "Element Plus Version",
|
||||||
|
"tdesign": "TDesign Vue Version"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"login": "Login",
|
||||||
|
"register": "Register",
|
||||||
|
"codeLogin": "Code Login",
|
||||||
|
"qrcodeLogin": "Qr Code Login",
|
||||||
|
"forgetPassword": "Forget Password",
|
||||||
|
"captchaPlaceholder": "Please enter the captcha",
|
||||||
|
"captchaRequired": "The captcha is required",
|
||||||
|
"profile": "Profile"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"analytics": "Analytics",
|
||||||
|
"workspace": "Workspace"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"system": "System",
|
||||||
|
"scheduler": "Scheduler",
|
||||||
|
"schedulerManage": "Manage",
|
||||||
|
"schedulerRecord": "Record",
|
||||||
|
"log": "System Log",
|
||||||
|
"login": "Login",
|
||||||
|
"monitor": "System Monitor",
|
||||||
|
"opera": "Opera",
|
||||||
|
"online": "Online",
|
||||||
|
"redis": "Redis",
|
||||||
|
"server": "Server",
|
||||||
|
"sysDataPermission": "Data Permission",
|
||||||
|
"sysDataScope": "Data Scope",
|
||||||
|
"sysDataRule": "Data Rule",
|
||||||
|
"sysPlugin": "Plugin",
|
||||||
|
"sysDept": "Dept",
|
||||||
|
"sysMenu": "User",
|
||||||
|
"sysRole": "Role",
|
||||||
|
"sysUser": "User",
|
||||||
|
"profile": "Profile"
|
||||||
|
},
|
||||||
|
"monitor": {
|
||||||
|
"redis": {
|
||||||
|
"cards": {
|
||||||
|
"commands": {
|
||||||
|
"title": "Command Statistics"
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"title": "Memory Status"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"title": "Server Info",
|
||||||
|
"redis_version": "Version",
|
||||||
|
"redis_mode": "Mode",
|
||||||
|
"role": "Role",
|
||||||
|
"tcp_port": "TCP Port",
|
||||||
|
"uptime": "Uptime",
|
||||||
|
"connected_clients": "Connected Clients",
|
||||||
|
"blocked_clients": "Blocked Clients",
|
||||||
|
"used_memory_human": "Used Memory",
|
||||||
|
"used_memory_rss_human": "RSS Memory",
|
||||||
|
"maxmemory_human": "Max Memory",
|
||||||
|
"mem_fragmentation_ratio": "Fragmentation Ratio",
|
||||||
|
"total_commands_processed": "Commands Processed",
|
||||||
|
"instantaneous_ops_per_sec": "Ops/Sec",
|
||||||
|
"rejected_connections": "Rejected Connections",
|
||||||
|
"keys_num": "Keys"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"cpu": {
|
||||||
|
"title": "CPU",
|
||||||
|
"current_freq": "Current Freq (MHz)",
|
||||||
|
"logical_num": "Logical Cores",
|
||||||
|
"physical_num": "Physical Cores",
|
||||||
|
"usage": "CPU Usage"
|
||||||
|
},
|
||||||
|
"disk": {
|
||||||
|
"title": "Disk",
|
||||||
|
"device": "Device",
|
||||||
|
"dir": "Mount Point",
|
||||||
|
"free": "Free",
|
||||||
|
"total": "Total",
|
||||||
|
"type": "Type",
|
||||||
|
"usage": "Usage",
|
||||||
|
"used": "Used"
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"title": "Memory",
|
||||||
|
"free": "Free",
|
||||||
|
"total": "Total",
|
||||||
|
"usage": "Usage",
|
||||||
|
"used": "Used"
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"title": "Service Info",
|
||||||
|
"name": "Service Name",
|
||||||
|
"version": "Version",
|
||||||
|
"home": "Home Path",
|
||||||
|
"cpu_usage": "CPU Usage",
|
||||||
|
"mem_vms": "Virtual Memory",
|
||||||
|
"mem_rss": "Physical Memory",
|
||||||
|
"mem_free": "Free Memory",
|
||||||
|
"startup": "Startup Time",
|
||||||
|
"elapsed": "Elapsed Time"
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"title": "System Info",
|
||||||
|
"name": "Hostname",
|
||||||
|
"ip": "IP Address",
|
||||||
|
"os": "OS",
|
||||||
|
"arch": "Architecture"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"form": {
|
||||||
|
"query": "查询",
|
||||||
|
"select": "请选择",
|
||||||
|
"status": "状态"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"created_time": "创建时间",
|
||||||
|
"id": "序号",
|
||||||
|
"mark": "备注",
|
||||||
|
"operation": "操作",
|
||||||
|
"updated_time": "更新时间"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"title": "演示",
|
||||||
|
"antd": "Antdv Next",
|
||||||
|
"vben": {
|
||||||
|
"title": "项目",
|
||||||
|
"about": "关于",
|
||||||
|
"document": "文档",
|
||||||
|
"antdv": "Ant Design Vue 版本",
|
||||||
|
"antdv-next": "Antdv Next 版本",
|
||||||
|
"naive-ui": "Naive UI 版本",
|
||||||
|
"element-plus": "Element Plus 版本",
|
||||||
|
"tdesign": "TDesign Vue 版本"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"login": "登录",
|
||||||
|
"register": "注册",
|
||||||
|
"codeLogin": "验证码登录",
|
||||||
|
"qrcodeLogin": "二维码登录",
|
||||||
|
"forgetPassword": "忘记密码",
|
||||||
|
"captchaPlaceholder": "请输入验证码",
|
||||||
|
"captchaRequired": "验证码是必须的",
|
||||||
|
"profile": "个人中心"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "概览",
|
||||||
|
"analytics": "分析页",
|
||||||
|
"workspace": "工作台"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"system": "系统管理",
|
||||||
|
"scheduler": "任务调度",
|
||||||
|
"schedulerManage": "任务管理",
|
||||||
|
"schedulerRecord": "执行记录",
|
||||||
|
"log": "日志管理",
|
||||||
|
"login": "登录日志",
|
||||||
|
"monitor": "系统监控",
|
||||||
|
"opera": "操作日志",
|
||||||
|
"online": "在线用户",
|
||||||
|
"redis": "Redis",
|
||||||
|
"server": "服务器",
|
||||||
|
"sysDataPermission": "数据权限",
|
||||||
|
"sysDataScope": "数据范围",
|
||||||
|
"sysDataRule": "数据规则",
|
||||||
|
"sysPlugin": "插件管理",
|
||||||
|
"sysDept": "部门管理",
|
||||||
|
"sysMenu": "菜单管理",
|
||||||
|
"sysRole": "角色管理",
|
||||||
|
"sysUser": "用户管理",
|
||||||
|
"profile": "个人中心"
|
||||||
|
},
|
||||||
|
"monitor": {
|
||||||
|
"redis": {
|
||||||
|
"cards": {
|
||||||
|
"commands": {
|
||||||
|
"title": "命令统计"
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"title": "内存使用"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"title": "服务器信息",
|
||||||
|
"redis_version": "版本",
|
||||||
|
"redis_mode": "运行模式",
|
||||||
|
"role": "节点角色",
|
||||||
|
"tcp_port": "监听端口",
|
||||||
|
"uptime": "运行时长",
|
||||||
|
"connected_clients": "已连接客户端数",
|
||||||
|
"blocked_clients": "阻塞客户端数",
|
||||||
|
"used_memory_human": "已使用内存",
|
||||||
|
"used_memory_rss_human": "RSS 内存",
|
||||||
|
"maxmemory_human": "最大内存限制",
|
||||||
|
"mem_fragmentation_ratio": "内存碎片率",
|
||||||
|
"total_commands_processed": "命令处理总数",
|
||||||
|
"instantaneous_ops_per_sec": "每秒操作数",
|
||||||
|
"rejected_connections": "拒绝连接数",
|
||||||
|
"keys_num": "键总数"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"cpu": {
|
||||||
|
"title": "CPU 信息",
|
||||||
|
"current_freq": "当前频率 (MHz)",
|
||||||
|
"logical_num": "逻辑核心数",
|
||||||
|
"physical_num": "物理核心数",
|
||||||
|
"usage": "CPU 使用率"
|
||||||
|
},
|
||||||
|
"disk": {
|
||||||
|
"title": "磁盘信息",
|
||||||
|
"device": "设备名称",
|
||||||
|
"dir": "挂载点",
|
||||||
|
"free": "可用",
|
||||||
|
"total": "总容量",
|
||||||
|
"type": "文件系统类型",
|
||||||
|
"usage": "使用率",
|
||||||
|
"used": "已使用"
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"free": "可用",
|
||||||
|
"title": "内存信息",
|
||||||
|
"total": "总量",
|
||||||
|
"usage": "使用率",
|
||||||
|
"used": "已使用"
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"title": "服务信息",
|
||||||
|
"name": "服务名称",
|
||||||
|
"version": "版本",
|
||||||
|
"home": "安装路径",
|
||||||
|
"cpu_usage": "CPU 使用率",
|
||||||
|
"mem_vms": "虚拟内存",
|
||||||
|
"mem_rss": "物理内存",
|
||||||
|
"mem_free": "可用内存",
|
||||||
|
"startup": "启动时间",
|
||||||
|
"elapsed": "运行时长"
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"title": "系统信息",
|
||||||
|
"name": "主机名",
|
||||||
|
"ip": "IP 地址",
|
||||||
|
"os": "操作系统",
|
||||||
|
"arch": "系统架构"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { initPreferences } from '@vben/preferences';
|
||||||
|
import { unmountGlobalLoading } from '@vben/utils';
|
||||||
|
|
||||||
|
import { overridesPreferences } from './preferences';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用初始化完成之后再进行页面加载渲染
|
||||||
|
*/
|
||||||
|
async function initApplication() {
|
||||||
|
// name用于指定项目唯一标识
|
||||||
|
// 用于区分不同项目的偏好设置以及存储数据的key前缀以及其他一些需要隔离的数据
|
||||||
|
const env = import.meta.env.PROD ? 'prod' : 'dev';
|
||||||
|
const appVersion = import.meta.env.VITE_APP_VERSION;
|
||||||
|
const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${appVersion}-${env}`;
|
||||||
|
|
||||||
|
// app偏好设置初始化
|
||||||
|
await initPreferences({
|
||||||
|
namespace,
|
||||||
|
overrides: overridesPreferences,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动应用并挂载
|
||||||
|
// vue应用主要逻辑及视图
|
||||||
|
const { bootstrap } = await import('./bootstrap');
|
||||||
|
await bootstrap(namespace);
|
||||||
|
|
||||||
|
// 移除并销毁loading
|
||||||
|
unmountGlobalLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
initApplication();
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
interface phoneCaptchaParams {
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送短信验证码
|
||||||
|
*/
|
||||||
|
export async function getPhoneCaptchaApi(data: phoneCaptchaParams) {
|
||||||
|
return requestClient.post('/api/v1/phones/captcha', data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "@fba-ui/aliyun-sms",
|
||||||
|
"version": "5.7.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import type { Recordable } from '@vben/types';
|
||||||
|
|
||||||
|
import type { PaginationResult } from '#/types';
|
||||||
|
|
||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
export interface QueryCodeGenBusinessParams {
|
||||||
|
table_name?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeGenBusinessParams {
|
||||||
|
app_name: string;
|
||||||
|
table_name: string;
|
||||||
|
doc_comment: string;
|
||||||
|
table_comment?: string;
|
||||||
|
class_name?: string;
|
||||||
|
schema_name?: string;
|
||||||
|
filename?: string;
|
||||||
|
datetime_mixin?: boolean;
|
||||||
|
api_version?: string;
|
||||||
|
tag?: string;
|
||||||
|
gen_path?: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeGenBusinessResult extends CodeGenBusinessParams {
|
||||||
|
id: number;
|
||||||
|
created_time: string;
|
||||||
|
updated_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeGenColumnParams {
|
||||||
|
name: string;
|
||||||
|
comment?: string;
|
||||||
|
type: string;
|
||||||
|
default?: string;
|
||||||
|
sort: number;
|
||||||
|
length: number;
|
||||||
|
is_pk: boolean;
|
||||||
|
is_nullable: boolean;
|
||||||
|
gen_business_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeGenColumnResult extends CodeGenColumnParams {
|
||||||
|
id: number;
|
||||||
|
pd_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeGenBusinessImportParams {
|
||||||
|
app: string;
|
||||||
|
table_schema: string;
|
||||||
|
table_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCodeGenBusinessDetailApi(pk: number) {
|
||||||
|
return requestClient.get<CodeGenBusinessResult>(
|
||||||
|
`/api/v1/code-generation/businesses/${pk}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllCodeGenBusinessApi() {
|
||||||
|
return requestClient.get<CodeGenBusinessResult[]>(
|
||||||
|
'/api/v1/code-generation/businesses/all',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCodeGenBusinessApi(data: CodeGenBusinessParams) {
|
||||||
|
return requestClient.post(`/api/v1/code-generation/businesses`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCodeGenBusinessApi(
|
||||||
|
pk: number,
|
||||||
|
data: CodeGenBusinessParams,
|
||||||
|
) {
|
||||||
|
return requestClient.put(`/api/v1/code-generation/businesses/${pk}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCodeGenBusinessApi(pk: number) {
|
||||||
|
return requestClient.delete(`/api/v1/code-generation/businesses/${pk}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCodeGenBusinessListApi(
|
||||||
|
params: QueryCodeGenBusinessParams,
|
||||||
|
) {
|
||||||
|
return requestClient.get<PaginationResult<CodeGenBusinessResult>>(
|
||||||
|
`/api/v1/code-generation/businesses`,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllCodeGenBusinessColumnApi(pk: number) {
|
||||||
|
return requestClient.get<PaginationResult<CodeGenColumnResult>>(
|
||||||
|
`/api/v1/code-generation/businesses/${pk}/columns`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllCodeGenColumnTypeApi() {
|
||||||
|
return requestClient.get<string[]>(`/api/v1/code-generation/columns/types`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCodeGenColumnDetailApi(pk: number) {
|
||||||
|
return requestClient.get(`/api/v1/code-generation/columns/${pk}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCodeGenColumnApi(data: CodeGenColumnParams) {
|
||||||
|
return requestClient.post(`/api/v1/code-generation/columns`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCodeGenColumnApi(
|
||||||
|
pk: number,
|
||||||
|
data: CodeGenColumnParams,
|
||||||
|
) {
|
||||||
|
return requestClient.put(`/api/v1/code-generation/columns/${pk}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCodeGenColumnApi(pk: number) {
|
||||||
|
return requestClient.delete(`/api/v1/code-generation/columns/${pk}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCodeGenDbTableApi(params: Recordable<any>) {
|
||||||
|
return requestClient.get<string[]>(
|
||||||
|
`/api/v1/code-generation/generations/tables`,
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importCodeGenDbTableApi(
|
||||||
|
data: CodeGenBusinessImportParams,
|
||||||
|
) {
|
||||||
|
return requestClient.post(
|
||||||
|
`/api/v1/code-generation/generations/imports`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewCodeGenApi(pk: number) {
|
||||||
|
return requestClient.get(`/api/v1/code-generation/generations/${pk}/preview`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCodeGenPathApi(pk: number) {
|
||||||
|
return requestClient.get<string[]>(
|
||||||
|
`/api/v1/code-generation/generations/${pk}/paths`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCodeApi(pk: number) {
|
||||||
|
return requestClient.post(`/api/v1/code-generation/generations/${pk}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadCodeApi(pk: number) {
|
||||||
|
return requestClient.download<Blob>(
|
||||||
|
`/api/v1/code-generation/generations/${pk}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
+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 };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user