76 changed files with 5401 additions and 11 deletions
@ -0,0 +1,7 @@ |
|||
# public path |
|||
VITE_BASE=/ |
|||
|
|||
# Basic interface address SPA |
|||
VITE_GLOB_API_URL=/api |
|||
|
|||
VITE_VISUALIZER=true |
|||
@ -0,0 +1,18 @@ |
|||
# 端口号 |
|||
VITE_PORT=5666 |
|||
|
|||
VITE_BASE=/ |
|||
|
|||
# 接口地址 |
|||
VITE_GLOB_API_URL=/api |
|||
# VITE_GLOB_API_URL=http://localhost:8083/api/models |
|||
VITE_GLOB_JAVA_API_URL=http://localhost:8083/api/models |
|||
|
|||
# 是否开启 Nitro Mock服务,true 为开启,false 为关闭 |
|||
VITE_NITRO_MOCK=true |
|||
|
|||
# 是否打开 devtools,true 为打开,false 为关闭 |
|||
VITE_DEVTOOLS=false |
|||
|
|||
# 是否注入全局loading |
|||
VITE_INJECT_APP_LOADING=true |
|||
@ -0,0 +1,22 @@ |
|||
VITE_BASE=/ |
|||
|
|||
# 接口地址 |
|||
VITE_GLOB_API_URL=https://mock-napi.vben.pro/api |
|||
|
|||
# VITE_GLOB_API_URL=http://localhost:8083/api/models |
|||
VITE_GLOB_JAVA_API_URL=http://localhost:8083/api/models |
|||
|
|||
# 是否开启压缩,可以设置为 none, brotli, gzip |
|||
VITE_COMPRESS=none |
|||
|
|||
# 是否开启 PWA |
|||
VITE_PWA=false |
|||
|
|||
# vue-router 的模式 |
|||
VITE_ROUTER_HISTORY=hash |
|||
|
|||
# 是否注入全局loading |
|||
VITE_INJECT_APP_LOADING=true |
|||
|
|||
# 打包后是否生成dist.zip |
|||
VITE_ARCHIVER=true |
|||
@ -0,0 +1,35 @@ |
|||
<!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="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" /> |
|||
<script> |
|||
// 生产环境下注入百度统计 |
|||
if (window._VBEN_ADMIN_PRO_APP_CONF_) { |
|||
var _hmt = _hmt || []; |
|||
(function () { |
|||
var hm = document.createElement('script'); |
|||
hm.src = |
|||
'https://hm.baidu.com/hm.js?b38e689f40558f20a9a686d7f6f33edf'; |
|||
var s = document.getElementsByTagName('script')[0]; |
|||
s.parentNode.insertBefore(hm, s); |
|||
})(); |
|||
} |
|||
</script> |
|||
</head> |
|||
<body> |
|||
<div id="app"></div> |
|||
<script type="module" src="/src/main.ts"></script> |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,511 @@ |
|||
/** |
|||
* 自动生成 Mock 数据 |
|||
*/ |
|||
|
|||
import { MockMethod } from 'vite-plugin-mock'; |
|||
|
|||
const list = [ |
|||
|
|||
|
|||
{ |
|||
|
|||
id: 'id_0', |
|||
|
|||
userName: 'userName_0', |
|||
|
|||
userPhone: 'userPhone_0', |
|||
|
|||
userRealName: 'userRealName_0', |
|||
|
|||
userGender: 'userGender_0', |
|||
|
|||
userPassword: 'userPassword_0', |
|||
|
|||
userBirthday: 'userBirthday_0', |
|||
|
|||
userFace: 'userFace_0', |
|||
|
|||
userEmail: 'userEmail_0', |
|||
|
|||
createdAt: 'createdAt_0', |
|||
|
|||
updatedAt: 'updatedAt_0', |
|||
|
|||
createdBy: 'createdBy_0', |
|||
|
|||
updatedBy: 'updatedBy_0', |
|||
|
|||
deletedFlag: 'deletedFlag_0', |
|||
|
|||
userAddress: 'userAddress_0', |
|||
|
|||
userId: 'userId_0', |
|||
|
|||
userSys: 'userSys_0', |
|||
|
|||
userPhoneModel: 'userPhoneModel_0', |
|||
|
|||
userStatus: 'userStatus_0', |
|||
|
|||
userCid: 'userCid_0', |
|||
|
|||
userIp: 'userIp_0', |
|||
|
|||
}, |
|||
|
|||
{ |
|||
|
|||
id: 'id_1', |
|||
|
|||
userName: 'userName_1', |
|||
|
|||
userPhone: 'userPhone_1', |
|||
|
|||
userRealName: 'userRealName_1', |
|||
|
|||
userGender: 'userGender_1', |
|||
|
|||
userPassword: 'userPassword_1', |
|||
|
|||
userBirthday: 'userBirthday_1', |
|||
|
|||
userFace: 'userFace_1', |
|||
|
|||
userEmail: 'userEmail_1', |
|||
|
|||
createdAt: 'createdAt_1', |
|||
|
|||
updatedAt: 'updatedAt_1', |
|||
|
|||
createdBy: 'createdBy_1', |
|||
|
|||
updatedBy: 'updatedBy_1', |
|||
|
|||
deletedFlag: 'deletedFlag_1', |
|||
|
|||
userAddress: 'userAddress_1', |
|||
|
|||
userId: 'userId_1', |
|||
|
|||
userSys: 'userSys_1', |
|||
|
|||
userPhoneModel: 'userPhoneModel_1', |
|||
|
|||
userStatus: 'userStatus_1', |
|||
|
|||
userCid: 'userCid_1', |
|||
|
|||
userIp: 'userIp_1', |
|||
|
|||
}, |
|||
|
|||
{ |
|||
|
|||
id: 'id_2', |
|||
|
|||
userName: 'userName_2', |
|||
|
|||
userPhone: 'userPhone_2', |
|||
|
|||
userRealName: 'userRealName_2', |
|||
|
|||
userGender: 'userGender_2', |
|||
|
|||
userPassword: 'userPassword_2', |
|||
|
|||
userBirthday: 'userBirthday_2', |
|||
|
|||
userFace: 'userFace_2', |
|||
|
|||
userEmail: 'userEmail_2', |
|||
|
|||
createdAt: 'createdAt_2', |
|||
|
|||
updatedAt: 'updatedAt_2', |
|||
|
|||
createdBy: 'createdBy_2', |
|||
|
|||
updatedBy: 'updatedBy_2', |
|||
|
|||
deletedFlag: 'deletedFlag_2', |
|||
|
|||
userAddress: 'userAddress_2', |
|||
|
|||
userId: 'userId_2', |
|||
|
|||
userSys: 'userSys_2', |
|||
|
|||
userPhoneModel: 'userPhoneModel_2', |
|||
|
|||
userStatus: 'userStatus_2', |
|||
|
|||
userCid: 'userCid_2', |
|||
|
|||
userIp: 'userIp_2', |
|||
|
|||
}, |
|||
|
|||
{ |
|||
|
|||
id: 'id_3', |
|||
|
|||
userName: 'userName_3', |
|||
|
|||
userPhone: 'userPhone_3', |
|||
|
|||
userRealName: 'userRealName_3', |
|||
|
|||
userGender: 'userGender_3', |
|||
|
|||
userPassword: 'userPassword_3', |
|||
|
|||
userBirthday: 'userBirthday_3', |
|||
|
|||
userFace: 'userFace_3', |
|||
|
|||
userEmail: 'userEmail_3', |
|||
|
|||
createdAt: 'createdAt_3', |
|||
|
|||
updatedAt: 'updatedAt_3', |
|||
|
|||
createdBy: 'createdBy_3', |
|||
|
|||
updatedBy: 'updatedBy_3', |
|||
|
|||
deletedFlag: 'deletedFlag_3', |
|||
|
|||
userAddress: 'userAddress_3', |
|||
|
|||
userId: 'userId_3', |
|||
|
|||
userSys: 'userSys_3', |
|||
|
|||
userPhoneModel: 'userPhoneModel_3', |
|||
|
|||
userStatus: 'userStatus_3', |
|||
|
|||
userCid: 'userCid_3', |
|||
|
|||
userIp: 'userIp_3', |
|||
|
|||
}, |
|||
|
|||
{ |
|||
|
|||
id: 'id_4', |
|||
|
|||
userName: 'userName_4', |
|||
|
|||
userPhone: 'userPhone_4', |
|||
|
|||
userRealName: 'userRealName_4', |
|||
|
|||
userGender: 'userGender_4', |
|||
|
|||
userPassword: 'userPassword_4', |
|||
|
|||
userBirthday: 'userBirthday_4', |
|||
|
|||
userFace: 'userFace_4', |
|||
|
|||
userEmail: 'userEmail_4', |
|||
|
|||
createdAt: 'createdAt_4', |
|||
|
|||
updatedAt: 'updatedAt_4', |
|||
|
|||
createdBy: 'createdBy_4', |
|||
|
|||
updatedBy: 'updatedBy_4', |
|||
|
|||
deletedFlag: 'deletedFlag_4', |
|||
|
|||
userAddress: 'userAddress_4', |
|||
|
|||
userId: 'userId_4', |
|||
|
|||
userSys: 'userSys_4', |
|||
|
|||
userPhoneModel: 'userPhoneModel_4', |
|||
|
|||
userStatus: 'userStatus_4', |
|||
|
|||
userCid: 'userCid_4', |
|||
|
|||
userIp: 'userIp_4', |
|||
|
|||
}, |
|||
|
|||
{ |
|||
|
|||
id: 'id_5', |
|||
|
|||
userName: 'userName_5', |
|||
|
|||
userPhone: 'userPhone_5', |
|||
|
|||
userRealName: 'userRealName_5', |
|||
|
|||
userGender: 'userGender_5', |
|||
|
|||
userPassword: 'userPassword_5', |
|||
|
|||
userBirthday: 'userBirthday_5', |
|||
|
|||
userFace: 'userFace_5', |
|||
|
|||
userEmail: 'userEmail_5', |
|||
|
|||
createdAt: 'createdAt_5', |
|||
|
|||
updatedAt: 'updatedAt_5', |
|||
|
|||
createdBy: 'createdBy_5', |
|||
|
|||
updatedBy: 'updatedBy_5', |
|||
|
|||
deletedFlag: 'deletedFlag_5', |
|||
|
|||
userAddress: 'userAddress_5', |
|||
|
|||
userId: 'userId_5', |
|||
|
|||
userSys: 'userSys_5', |
|||
|
|||
userPhoneModel: 'userPhoneModel_5', |
|||
|
|||
userStatus: 'userStatus_5', |
|||
|
|||
userCid: 'userCid_5', |
|||
|
|||
userIp: 'userIp_5', |
|||
|
|||
}, |
|||
|
|||
{ |
|||
|
|||
id: 'id_6', |
|||
|
|||
userName: 'userName_6', |
|||
|
|||
userPhone: 'userPhone_6', |
|||
|
|||
userRealName: 'userRealName_6', |
|||
|
|||
userGender: 'userGender_6', |
|||
|
|||
userPassword: 'userPassword_6', |
|||
|
|||
userBirthday: 'userBirthday_6', |
|||
|
|||
userFace: 'userFace_6', |
|||
|
|||
userEmail: 'userEmail_6', |
|||
|
|||
createdAt: 'createdAt_6', |
|||
|
|||
updatedAt: 'updatedAt_6', |
|||
|
|||
createdBy: 'createdBy_6', |
|||
|
|||
updatedBy: 'updatedBy_6', |
|||
|
|||
deletedFlag: 'deletedFlag_6', |
|||
|
|||
userAddress: 'userAddress_6', |
|||
|
|||
userId: 'userId_6', |
|||
|
|||
userSys: 'userSys_6', |
|||
|
|||
userPhoneModel: 'userPhoneModel_6', |
|||
|
|||
userStatus: 'userStatus_6', |
|||
|
|||
userCid: 'userCid_6', |
|||
|
|||
userIp: 'userIp_6', |
|||
|
|||
}, |
|||
|
|||
{ |
|||
|
|||
id: 'id_7', |
|||
|
|||
userName: 'userName_7', |
|||
|
|||
userPhone: 'userPhone_7', |
|||
|
|||
userRealName: 'userRealName_7', |
|||
|
|||
userGender: 'userGender_7', |
|||
|
|||
userPassword: 'userPassword_7', |
|||
|
|||
userBirthday: 'userBirthday_7', |
|||
|
|||
userFace: 'userFace_7', |
|||
|
|||
userEmail: 'userEmail_7', |
|||
|
|||
createdAt: 'createdAt_7', |
|||
|
|||
updatedAt: 'updatedAt_7', |
|||
|
|||
createdBy: 'createdBy_7', |
|||
|
|||
updatedBy: 'updatedBy_7', |
|||
|
|||
deletedFlag: 'deletedFlag_7', |
|||
|
|||
userAddress: 'userAddress_7', |
|||
|
|||
userId: 'userId_7', |
|||
|
|||
userSys: 'userSys_7', |
|||
|
|||
userPhoneModel: 'userPhoneModel_7', |
|||
|
|||
userStatus: 'userStatus_7', |
|||
|
|||
userCid: 'userCid_7', |
|||
|
|||
userIp: 'userIp_7', |
|||
|
|||
}, |
|||
|
|||
{ |
|||
|
|||
id: 'id_8', |
|||
|
|||
userName: 'userName_8', |
|||
|
|||
userPhone: 'userPhone_8', |
|||
|
|||
userRealName: 'userRealName_8', |
|||
|
|||
userGender: 'userGender_8', |
|||
|
|||
userPassword: 'userPassword_8', |
|||
|
|||
userBirthday: 'userBirthday_8', |
|||
|
|||
userFace: 'userFace_8', |
|||
|
|||
userEmail: 'userEmail_8', |
|||
|
|||
createdAt: 'createdAt_8', |
|||
|
|||
updatedAt: 'updatedAt_8', |
|||
|
|||
createdBy: 'createdBy_8', |
|||
|
|||
updatedBy: 'updatedBy_8', |
|||
|
|||
deletedFlag: 'deletedFlag_8', |
|||
|
|||
userAddress: 'userAddress_8', |
|||
|
|||
userId: 'userId_8', |
|||
|
|||
userSys: 'userSys_8', |
|||
|
|||
userPhoneModel: 'userPhoneModel_8', |
|||
|
|||
userStatus: 'userStatus_8', |
|||
|
|||
userCid: 'userCid_8', |
|||
|
|||
userIp: 'userIp_8', |
|||
|
|||
}, |
|||
|
|||
{ |
|||
|
|||
id: 'id_9', |
|||
|
|||
userName: 'userName_9', |
|||
|
|||
userPhone: 'userPhone_9', |
|||
|
|||
userRealName: 'userRealName_9', |
|||
|
|||
userGender: 'userGender_9', |
|||
|
|||
userPassword: 'userPassword_9', |
|||
|
|||
userBirthday: 'userBirthday_9', |
|||
|
|||
userFace: 'userFace_9', |
|||
|
|||
userEmail: 'userEmail_9', |
|||
|
|||
createdAt: 'createdAt_9', |
|||
|
|||
updatedAt: 'updatedAt_9', |
|||
|
|||
createdBy: 'createdBy_9', |
|||
|
|||
updatedBy: 'updatedBy_9', |
|||
|
|||
deletedFlag: 'deletedFlag_9', |
|||
|
|||
userAddress: 'userAddress_9', |
|||
|
|||
userId: 'userId_9', |
|||
|
|||
userSys: 'userSys_9', |
|||
|
|||
userPhoneModel: 'userPhoneModel_9', |
|||
|
|||
userStatus: 'userStatus_9', |
|||
|
|||
userCid: 'userCid_9', |
|||
|
|||
userIp: 'userIp_9', |
|||
|
|||
}, |
|||
|
|||
|
|||
]; |
|||
|
|||
export default [ |
|||
|
|||
{ |
|||
url: '/api/health_user/page', |
|||
method: 'get', |
|||
response: () => { |
|||
return { |
|||
code: 0, |
|||
data: { |
|||
records: list, |
|||
total: list.length, |
|||
}, |
|||
}; |
|||
}, |
|||
}, |
|||
|
|||
{ |
|||
url: '/api/health_user', |
|||
method: 'post', |
|||
response: () => { |
|||
return { |
|||
code: 0, |
|||
message: 'success', |
|||
}; |
|||
}, |
|||
}, |
|||
|
|||
{ |
|||
url: '/api/health_user/:id', |
|||
method: 'delete', |
|||
response: () => { |
|||
return { |
|||
code: 0, |
|||
message: 'deleted', |
|||
}; |
|||
}, |
|||
}, |
|||
|
|||
] as MockMethod[]; |
|||
@ -0,0 +1,50 @@ |
|||
{ |
|||
"name": "@vben/web-antd", |
|||
"version": "5.6.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-antd" |
|||
}, |
|||
"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:", |
|||
"ant-design-vue": "catalog:", |
|||
"dayjs": "catalog:", |
|||
"pinia": "catalog:", |
|||
"vue": "catalog:", |
|||
"vue-router": "catalog:" |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export { default } from '@vben/tailwind-config/postcss'; |
|||
|
After Width: | Height: | Size: 5.3 KiB |
@ -0,0 +1,595 @@ |
|||
/** |
|||
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用 |
|||
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用, |
|||
*/ |
|||
|
|||
/* eslint-disable vue/one-component-per-file */ |
|||
|
|||
import type { |
|||
UploadChangeParam, |
|||
UploadFile, |
|||
UploadProps, |
|||
} from 'ant-design-vue'; |
|||
|
|||
import type { Component, Ref } from 'vue'; |
|||
|
|||
import type { BaseFormComponentType } from '@vben/common-ui'; |
|||
import type { Recordable } from '@vben/types'; |
|||
|
|||
import { |
|||
computed, |
|||
defineAsyncComponent, |
|||
defineComponent, |
|||
h, |
|||
ref, |
|||
render, |
|||
unref, |
|||
watch, |
|||
} from 'vue'; |
|||
|
|||
import { |
|||
ApiComponent, |
|||
globalShareState, |
|||
IconPicker, |
|||
VCropper, |
|||
} from '@vben/common-ui'; |
|||
import { IconifyIcon } from '@vben/icons'; |
|||
import { $t } from '@vben/locales'; |
|||
import { isEmpty } from '@vben/utils'; |
|||
|
|||
import { message, Modal, notification } from 'ant-design-vue'; |
|||
|
|||
const AutoComplete = defineAsyncComponent( |
|||
() => import('ant-design-vue/es/auto-complete'), |
|||
); |
|||
const Button = defineAsyncComponent(() => import('ant-design-vue/es/button')); |
|||
const Checkbox = defineAsyncComponent( |
|||
() => import('ant-design-vue/es/checkbox'), |
|||
); |
|||
const CheckboxGroup = defineAsyncComponent(() => |
|||
import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup), |
|||
); |
|||
const DatePicker = defineAsyncComponent( |
|||
() => import('ant-design-vue/es/date-picker'), |
|||
); |
|||
const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider')); |
|||
const Input = defineAsyncComponent(() => import('ant-design-vue/es/input')); |
|||
const InputNumber = defineAsyncComponent( |
|||
() => import('ant-design-vue/es/input-number'), |
|||
); |
|||
const InputPassword = defineAsyncComponent(() => |
|||
import('ant-design-vue/es/input').then((res) => res.InputPassword), |
|||
); |
|||
const Mentions = defineAsyncComponent( |
|||
() => import('ant-design-vue/es/mentions'), |
|||
); |
|||
const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio')); |
|||
const RadioGroup = defineAsyncComponent(() => |
|||
import('ant-design-vue/es/radio').then((res) => res.RadioGroup), |
|||
); |
|||
const RangePicker = defineAsyncComponent(() => |
|||
import('ant-design-vue/es/date-picker').then((res) => res.RangePicker), |
|||
); |
|||
const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate')); |
|||
const Select = defineAsyncComponent(() => import('ant-design-vue/es/select')); |
|||
const Space = defineAsyncComponent(() => import('ant-design-vue/es/space')); |
|||
const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch')); |
|||
const Textarea = defineAsyncComponent(() => |
|||
import('ant-design-vue/es/input').then((res) => res.Textarea), |
|||
); |
|||
const TimePicker = defineAsyncComponent( |
|||
() => import('ant-design-vue/es/time-picker'), |
|||
); |
|||
const TreeSelect = defineAsyncComponent( |
|||
() => import('ant-design-vue/es/tree-select'), |
|||
); |
|||
const Cascader = defineAsyncComponent( |
|||
() => import('ant-design-vue/es/cascader'), |
|||
); |
|||
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload')); |
|||
const Image = defineAsyncComponent(() => import('ant-design-vue/es/image')); |
|||
const PreviewGroup = defineAsyncComponent(() => |
|||
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup), |
|||
); |
|||
|
|||
const withDefaultPlaceholder = <T extends Component>( |
|||
component: T, |
|||
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 withPreviewUpload = () => { |
|||
// 检查是否为图片文件的辅助函数
|
|||
const isImageFile = (file: UploadFile): boolean => { |
|||
const imageExtensions = new Set([ |
|||
'bmp', |
|||
'gif', |
|||
'jpeg', |
|||
'jpg', |
|||
'png', |
|||
'svg', |
|||
'webp', |
|||
]); |
|||
if (file.url) { |
|||
try { |
|||
const pathname = new URL(file.url, 'http://localhost').pathname; |
|||
const ext = pathname.split('.').pop()?.toLowerCase(); |
|||
return ext ? imageExtensions.has(ext) : false; |
|||
} catch { |
|||
const ext = file.url?.split('.').pop()?.toLowerCase(); |
|||
return ext ? imageExtensions.has(ext) : false; |
|||
} |
|||
} |
|||
if (!file.type) { |
|||
const ext = file.name?.split('.').pop()?.toLowerCase(); |
|||
return ext ? imageExtensions.has(ext) : false; |
|||
} |
|||
return file.type.startsWith('image/'); |
|||
}; |
|||
// 创建默认的上传按钮插槽
|
|||
const createDefaultSlotsWithUpload = ( |
|||
listType: string, |
|||
placeholder: string, |
|||
) => { |
|||
switch (listType) { |
|||
case 'picture-card': { |
|||
return { |
|||
default: () => placeholder, |
|||
}; |
|||
} |
|||
default: { |
|||
return { |
|||
default: () => |
|||
h( |
|||
Button, |
|||
{ |
|||
icon: h(IconifyIcon, { |
|||
icon: 'ant-design:upload-outlined', |
|||
class: 'mb-1 size-4', |
|||
}), |
|||
}, |
|||
() => placeholder, |
|||
), |
|||
}; |
|||
} |
|||
} |
|||
}; |
|||
// 构建预览图片组
|
|||
const previewImage = async ( |
|||
file: UploadFile, |
|||
visible: Ref<boolean>, |
|||
fileList: Ref<UploadProps['fileList']>, |
|||
) => { |
|||
// 如果当前文件不是图片,直接打开
|
|||
if (!isImageFile(file)) { |
|||
if (file.url) { |
|||
window.open(file.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 getBase64 = (file: File) => { |
|||
return new Promise((resolve, reject) => { |
|||
const reader = new FileReader(); |
|||
reader.readAsDataURL(file); |
|||
reader.addEventListener('load', () => resolve(reader.result)); |
|||
reader.addEventListener('error', (error) => reject(error)); |
|||
}); |
|||
}; |
|||
// 从fileList中过滤出所有图片文件
|
|||
const imageFiles = (unref(fileList) || []).filter((element) => |
|||
isImageFile(element), |
|||
); |
|||
|
|||
// 为所有没有预览地址的图片生成预览
|
|||
for (const imgFile of imageFiles) { |
|||
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) { |
|||
imgFile.preview = (await getBase64(imgFile.originFileObj)) as string; |
|||
} |
|||
} |
|||
const container: HTMLElement | null = document.createElement('div'); |
|||
document.body.append(container); |
|||
|
|||
// 用于追踪组件是否已卸载
|
|||
let isUnmounted = false; |
|||
|
|||
const PreviewWrapper = { |
|||
setup() { |
|||
return () => { |
|||
if (isUnmounted) return null; |
|||
return h( |
|||
PreviewGroupComponent, |
|||
{ |
|||
class: 'hidden', |
|||
preview: { |
|||
visible: visible.value, |
|||
// 设置初始显示的图片索引
|
|||
current: imageFiles.findIndex((f) => f.uid === file.uid), |
|||
onVisibleChange: (value: boolean) => { |
|||
visible.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); |
|||
}; |
|||
|
|||
// 图片裁剪操作
|
|||
const cropImage = (file: File, aspectRatio: string | undefined) => { |
|||
return new Promise((resolve, reject) => { |
|||
const container: HTMLElement | null = 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); |
|||
|
|||
const 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'), |
|||
destroyOnClose: true, |
|||
onOk: async () => { |
|||
const cropper = cropperRef.value; |
|||
if (!cropper) { |
|||
reject(new Error('Cropper not found')); |
|||
closeModal(); |
|||
return; |
|||
} |
|||
try { |
|||
const dataUrl = await cropper.getCropImage(); |
|||
resolve(dataUrl); |
|||
} 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); |
|||
}); |
|||
}; |
|||
|
|||
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'], |
|||
); |
|||
|
|||
const handleBeforeUpload = async ( |
|||
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'; |
|||
// antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取
|
|||
const blob = await cropImage(originFileList[0], aspectRatio.value); |
|||
return new Promise((resolve, reject) => { |
|||
if (!blob) { |
|||
return reject(new Error($t('ui.crop.errorTip'))); |
|||
} |
|||
resolve(blob); |
|||
}); |
|||
} |
|||
|
|||
return attrs.beforeUpload?.(file) ?? true; |
|||
}; |
|||
|
|||
const handleChange = (event: UploadChangeParam) => { |
|||
try { |
|||
// 行内写法 handleChange: (event) => {}
|
|||
attrs.handleChange?.(event); |
|||
// template写法 @handle-change="(event) => {}"
|
|||
attrs.onHandleChange?.(event); |
|||
} catch (error) { |
|||
// Avoid breaking internal v-model sync on user handler errors
|
|||
console.error(error); |
|||
} |
|||
fileList.value = event.fileList.filter( |
|||
(file) => file.status !== 'removed', |
|||
); |
|||
emit( |
|||
'update:modelValue', |
|||
event.fileList?.length ? fileList.value : undefined, |
|||
); |
|||
}; |
|||
|
|||
const handlePreview = async (file: UploadFile) => { |
|||
previewVisible.value = true; |
|||
await previewImage(file, previewVisible, fileList); |
|||
}; |
|||
|
|||
const renderUploadButton = (): any => { |
|||
const isDisabled = attrs.disabled; |
|||
|
|||
// 如果禁用,不渲染上传按钮
|
|||
if (isDisabled) { |
|||
return null; |
|||
} |
|||
|
|||
// 否则渲染默认上传按钮
|
|||
return isEmpty(slots) |
|||
? createDefaultSlotsWithUpload(listType, placeholder) |
|||
: slots; |
|||
}; |
|||
|
|||
// 可以监听到表单API设置的值
|
|||
watch( |
|||
() => attrs.modelValue, |
|||
(res) => { |
|||
fileList.value = res; |
|||
}, |
|||
); |
|||
|
|||
return () => |
|||
h( |
|||
Upload, |
|||
{ |
|||
...props, |
|||
...attrs, |
|||
fileList: fileList.value, |
|||
beforeUpload: handleBeforeUpload, |
|||
onChange: handleChange, |
|||
onPreview: handlePreview, |
|||
}, |
|||
renderUploadButton(), |
|||
); |
|||
}, |
|||
}); |
|||
}; |
|||
|
|||
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
|||
export type ComponentType = |
|||
| 'ApiCascader' |
|||
| 'ApiSelect' |
|||
| 'ApiTreeSelect' |
|||
| 'AutoComplete' |
|||
| 'Cascader' |
|||
| 'Checkbox' |
|||
| 'CheckboxGroup' |
|||
| 'DatePicker' |
|||
| 'DefaultButton' |
|||
| 'Divider' |
|||
| 'IconPicker' |
|||
| 'Input' |
|||
| 'InputNumber' |
|||
| 'InputPassword' |
|||
| 'Mentions' |
|||
| 'PrimaryButton' |
|||
| 'Radio' |
|||
| 'RadioGroup' |
|||
| 'RangePicker' |
|||
| 'Rate' |
|||
| 'Select' |
|||
| 'Space' |
|||
| 'Switch' |
|||
| 'Textarea' |
|||
| 'TimePicker' |
|||
| 'TreeSelect' |
|||
| 'Upload' |
|||
| BaseFormComponentType; |
|||
|
|||
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: 'onVisibleChange', |
|||
}), |
|||
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', { |
|||
component: Select, |
|||
loadingSlot: 'suffixIcon', |
|||
modelPropName: 'value', |
|||
visibleEvent: 'onVisibleChange', |
|||
}), |
|||
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', { |
|||
component: TreeSelect, |
|||
fieldNames: { label: 'label', value: 'value', children: 'children' }, |
|||
loadingSlot: 'suffixIcon', |
|||
modelPropName: 'value', |
|||
optionsPropName: 'treeData', |
|||
visibleEvent: 'onVisibleChange', |
|||
}), |
|||
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'), |
|||
InputPassword: withDefaultPlaceholder(InputPassword, 'input'), |
|||
Mentions: withDefaultPlaceholder(Mentions, 'input'), |
|||
// 自定义主要按钮
|
|||
PrimaryButton: (props, { attrs, slots }) => { |
|||
return h(Button, { ...props, attrs, type: 'primary' }, slots); |
|||
}, |
|||
Radio, |
|||
RadioGroup, |
|||
RangePicker, |
|||
Rate, |
|||
Select: withDefaultPlaceholder(Select, 'select'), |
|||
Space, |
|||
Switch, |
|||
Textarea: withDefaultPlaceholder(Textarea, 'input'), |
|||
TimePicker, |
|||
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'), |
|||
Upload: withPreviewUpload(), |
|||
}; |
|||
|
|||
// 将组件注册到全局共享状态中
|
|||
globalShareState.setComponents(components); |
|||
|
|||
// 定义全局共享状态中的消息提示
|
|||
globalShareState.defineMessage({ |
|||
// 复制成功消息提示
|
|||
copyPreferencesSuccess: (title, content) => { |
|||
notification.success({ |
|||
description: content, |
|||
message: title, |
|||
placement: 'bottomRight', |
|||
}); |
|||
}, |
|||
}); |
|||
} |
|||
|
|||
export { initComponentAdapter }; |
|||
@ -0,0 +1,49 @@ |
|||
import type { |
|||
VbenFormSchema as FormSchema, |
|||
VbenFormProps, |
|||
} from '@vben/common-ui'; |
|||
|
|||
import type { 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>; |
|||
|
|||
export { initSetupVbenForm, useVbenForm, z }; |
|||
|
|||
export type VbenFormSchema = FormSchema<ComponentType>; |
|||
export type { VbenFormProps }; |
|||
@ -0,0 +1,73 @@ |
|||
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table'; |
|||
|
|||
import { h } from 'vue'; |
|||
|
|||
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table'; |
|||
|
|||
import { Button, Image } from 'ant-design-vue'; |
|||
|
|||
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: 'items', |
|||
}, |
|||
showActiveMsg: true, |
|||
showResponseMsg: false, |
|||
}, |
|||
pagerConfig: { |
|||
pageSize: 10, // 👈 全局设置默认每页 10 条
|
|||
}, |
|||
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 }, |
|||
); |
|||
}, |
|||
}); |
|||
|
|||
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
|
|||
// vxeUI.formats.add
|
|||
}, |
|||
useVbenForm, |
|||
}); |
|||
|
|||
export { useVbenVxeGrid }; |
|||
|
|||
export type * from '@vben/plugins/vxe-table'; |
|||
@ -0,0 +1,51 @@ |
|||
import { baseRequestClient, requestClient } from '#/api/request'; |
|||
|
|||
export namespace AuthApi { |
|||
/** 登录接口参数 */ |
|||
export interface LoginParams { |
|||
password?: string; |
|||
username?: string; |
|||
} |
|||
|
|||
/** 登录接口返回值 */ |
|||
export interface LoginResult { |
|||
accessToken: string; |
|||
} |
|||
|
|||
export interface RefreshTokenResult { |
|||
data: string; |
|||
status: number; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 登录 |
|||
*/ |
|||
export async function loginApi(data: AuthApi.LoginParams) { |
|||
return requestClient.post<AuthApi.LoginResult>('/auth/login', data); |
|||
} |
|||
|
|||
/** |
|||
* 刷新accessToken |
|||
*/ |
|||
export async function refreshTokenApi() { |
|||
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', { |
|||
withCredentials: true, |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* 退出登录 |
|||
*/ |
|||
export async function logoutApi() { |
|||
return baseRequestClient.post('/auth/logout', { |
|||
withCredentials: true, |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* 获取用户权限码 |
|||
*/ |
|||
export async function getAccessCodesApi() { |
|||
return requestClient.get<string[]>('/auth/codes'); |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
export * from './auth'; |
|||
export * from './menu'; |
|||
export * from './user'; |
|||
@ -0,0 +1,10 @@ |
|||
import type { RouteRecordStringComponent } from '@vben/types'; |
|||
|
|||
import { requestClient } from '#/api/request'; |
|||
|
|||
/** |
|||
* 获取用户所有菜单 |
|||
*/ |
|||
export async function getAllMenusApi() { |
|||
return requestClient.get<RouteRecordStringComponent[]>('/menu/all'); |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
import type { UserInfo } from '@vben/types'; |
|||
|
|||
import { requestClient } from '#/api/request'; |
|||
|
|||
/** |
|||
* 获取用户信息 |
|||
*/ |
|||
export async function getUserInfoApi() { |
|||
return requestClient.get<UserInfo>('/user/info'); |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './core'; |
|||
@ -0,0 +1,113 @@ |
|||
/** |
|||
* 该文件可自行根据业务逻辑进行调整 |
|||
*/ |
|||
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 'ant-design-vue'; |
|||
|
|||
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); |
|||
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.data; |
|||
accessStore.setAccessToken(newToken); |
|||
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: 0, |
|||
}), |
|||
); |
|||
|
|||
// 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?.message ?? ''; |
|||
// 如果没有错误信息,则会根据状态码进行提示
|
|||
message.error(errorMessage || msg); |
|||
}), |
|||
); |
|||
|
|||
return client; |
|||
} |
|||
|
|||
export const requestClient = createRequestClient(apiURL, { |
|||
responseReturn: 'data', |
|||
}); |
|||
|
|||
export const baseRequestClient = new RequestClient({ baseURL: apiURL }); |
|||
@ -0,0 +1,50 @@ |
|||
/** |
|||
* 自动生成 API |
|||
* 负责调用后端接口 |
|||
*/ |
|||
|
|||
import { requestClient } from '#/api/request'; |
|||
import { useAppConfig } from '@vben/hooks'; |
|||
|
|||
export namespace userApi { |
|||
|
|||
const applicationConfig = useAppConfig(import.meta.env, import.meta.env.PROD); |
|||
console.log('=== 接口域名 ===', applicationConfig.javaURL) |
|||
|
|||
/** |
|||
* 分页查询 |
|||
*/ |
|||
export function page(params: any) { |
|||
return requestClient.post(applicationConfig.javaURL+'/health-user/page', params,{ headers: {'Content-Type': 'application/json', Token: 'ded93460-0cf5-45db-81ae-7608dbd3f51e', version: '1.0.1'}}); |
|||
} |
|||
|
|||
/** |
|||
* 获取详情 |
|||
*/ |
|||
export function get(id: number) { |
|||
return requestClient.get(applicationConfig.javaURL+'/health-user/' + id); |
|||
} |
|||
|
|||
/** |
|||
* 新增 / 修改 |
|||
*/ |
|||
export function save(data: any) { |
|||
return requestClient.post(applicationConfig.javaURL+'/health-user', data); |
|||
} |
|||
|
|||
/** |
|||
* 删除 |
|||
*/ |
|||
export function remove(id: number) { |
|||
return requestClient.delete(applicationConfig.javaURL+'/health-user/' + id); |
|||
} |
|||
|
|||
|
|||
/** |
|||
* 枚举列表 |
|||
*/ |
|||
export function enumList(params: any) { |
|||
return requestClient.post(applicationConfig.javaURL+'/health-enums/optionList', params,{ headers: {'Content-Type': 'application/json', Token: 'ded93460-0cf5-45db-81ae-7608dbd3f51e', version: '1.0.1'}}); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,39 @@ |
|||
<script lang="ts" setup> |
|||
import { computed } from 'vue'; |
|||
|
|||
import { useAntdDesignTokens } from '@vben/hooks'; |
|||
import { preferences, usePreferences } from '@vben/preferences'; |
|||
|
|||
import { App, ConfigProvider, theme } from 'ant-design-vue'; |
|||
|
|||
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, |
|||
}; |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<ConfigProvider :locale="antdLocale" :theme="tokenTheme"> |
|||
<App> |
|||
<RouterView /> |
|||
</App> |
|||
</ConfigProvider> |
|||
</template> |
|||
@ -0,0 +1,77 @@ |
|||
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/antd'; |
|||
|
|||
import { useTitle } from '@vueuse/core'; |
|||
|
|||
import { $t, setupI18n } from '#/locales'; |
|||
|
|||
import { initComponentAdapter } from './adapter/component'; |
|||
import { initSetupVbenForm } from './adapter/form'; |
|||
import App from './app.vue'; |
|||
import { router } from './router'; |
|||
|
|||
import './adapter/vxe-table'; |
|||
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); |
|||
|
|||
// 配置路由及路由守卫
|
|||
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,206 @@ |
|||
<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 } 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 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)); |
|||
} |
|||
watch( |
|||
() => ({ |
|||
enable: preferences.app.watermark, |
|||
content: preferences.app.watermarkContent, |
|||
}), |
|||
async ({ enable, content }) => { |
|||
if (enable) { |
|||
await updateWatermark({ |
|||
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" |
|||
/> |
|||
</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" |
|||
/> |
|||
</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,102 @@ |
|||
import type { Locale } from 'ant-design-vue/es/locale'; |
|||
|
|||
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 'ant-design-vue/es/locale/en_US'; |
|||
import antdDefaultLocale from 'ant-design-vue/es/locale/zh_CN'; |
|||
import dayjs from 'dayjs'; |
|||
|
|||
const antdLocale = ref<Locale>(antdDefaultLocale); |
|||
|
|||
const modules = import.meta.glob('./langs/**/*.json'); |
|||
|
|||
const localesMap = loadLocalesMapFromDir( |
|||
/\.\/langs\/([^/]+)\/(.*)\.json$/, |
|||
modules, |
|||
); |
|||
/** |
|||
* 加载应用特有的语言包 |
|||
* 这里也可以改造为从服务端获取翻译数据 |
|||
* @param lang |
|||
*/ |
|||
async function loadMessages(lang: SupportedLanguagesType) { |
|||
const [appLocaleMessages] = await Promise.all([ |
|||
localesMap[lang]?.(), |
|||
loadThirdPartyMessage(lang), |
|||
]); |
|||
return appLocaleMessages?.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 @@ |
|||
{ |
|||
"title": "Demos", |
|||
"antd": "Ant Design Vue", |
|||
"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,15 @@ |
|||
{ |
|||
"auth": { |
|||
"login": "Login", |
|||
"register": "Register", |
|||
"codeLogin": "Code Login", |
|||
"qrcodeLogin": "Qr Code Login", |
|||
"forgetPassword": "Forget Password", |
|||
"profile": "Profile" |
|||
}, |
|||
"dashboard": { |
|||
"title": "Dashboard", |
|||
"analytics": "Analytics", |
|||
"workspace": "Workspace" |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
{ |
|||
"title": "演示", |
|||
"antd": "Ant Design Vue", |
|||
"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,15 @@ |
|||
{ |
|||
"auth": { |
|||
"login": "登录", |
|||
"register": "注册", |
|||
"codeLogin": "验证码登录", |
|||
"qrcodeLogin": "二维码登录", |
|||
"forgetPassword": "忘记密码", |
|||
"profile": "个人中心" |
|||
}, |
|||
"dashboard": { |
|||
"title": "概览", |
|||
"analytics": "分析页", |
|||
"workspace": "工作台" |
|||
} |
|||
} |
|||
@ -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,13 @@ |
|||
import { defineOverridesPreferences } from '@vben/preferences'; |
|||
|
|||
/** |
|||
* @description 项目配置文件 |
|||
* 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置 |
|||
* !!! 更改配置后请清空缓存,否则可能不生效 |
|||
*/ |
|||
export const overridesPreferences = defineOverridesPreferences({ |
|||
// overrides
|
|||
app: { |
|||
name: import.meta.env.VITE_APP_TITLE, |
|||
}, |
|||
}); |
|||
@ -0,0 +1,42 @@ |
|||
import type { |
|||
ComponentRecordType, |
|||
GenerateMenuAndRoutesOptions, |
|||
} from '@vben/types'; |
|||
|
|||
import { generateAccessible } from '@vben/access'; |
|||
import { preferences } from '@vben/preferences'; |
|||
|
|||
import { message } from 'ant-design-vue'; |
|||
|
|||
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'); |
|||
|
|||
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,133 @@ |
|||
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 } 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(); |
|||
|
|||
// 基本路由,这些路由不需要进入权限拦截
|
|||
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, |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* 项目守卫配置 |
|||
* @param router |
|||
*/ |
|||
function createRouterGuard(router: Router) { |
|||
/** 通用 */ |
|||
setupCommonGuard(router); |
|||
/** 权限访问 */ |
|||
setupAccessGuard(router); |
|||
} |
|||
|
|||
export { createRouterGuard }; |
|||
@ -0,0 +1,37 @@ |
|||
import { |
|||
createRouter, |
|||
createWebHashHistory, |
|||
createWebHistory, |
|||
} from 'vue-router'; |
|||
|
|||
import { resetStaticRoutes } from '@vben/utils'; |
|||
|
|||
import { createRouterGuard } from './guard'; |
|||
import { routes } from './routes'; |
|||
|
|||
/** |
|||
* @zh_CN 创建vue-router实例 |
|||
*/ |
|||
const router = createRouter({ |
|||
history: |
|||
import.meta.env.VITE_ROUTER_HISTORY === 'hash' |
|||
? createWebHashHistory(import.meta.env.VITE_BASE) |
|||
: createWebHistory(import.meta.env.VITE_BASE), |
|||
// 应该添加到路由的初始路由列表。
|
|||
routes, |
|||
scrollBehavior: (to, _from, savedPosition) => { |
|||
if (savedPosition) { |
|||
return savedPosition; |
|||
} |
|||
return to.hash ? { behavior: 'smooth', el: to.hash } : { left: 0, top: 0 }; |
|||
}, |
|||
// 是否应该禁止尾部斜杠。
|
|||
// strict: true,
|
|||
}); |
|||
|
|||
const resetRoutes = () => resetStaticRoutes(router, routes); |
|||
|
|||
// 创建路由守卫
|
|||
createRouterGuard(router); |
|||
|
|||
export { resetRoutes, router }; |
|||
@ -0,0 +1,97 @@ |
|||
import type { RouteRecordRaw } from 'vue-router'; |
|||
|
|||
import { LOGIN_PATH } from '@vben/constants'; |
|||
import { preferences } from '@vben/preferences'; |
|||
|
|||
import { $t } from '#/locales'; |
|||
|
|||
const BasicLayout = () => import('#/layouts/basic.vue'); |
|||
const AuthPageLayout = () => import('#/layouts/auth.vue'); |
|||
/** 全局404页面 */ |
|||
const fallbackNotFoundRoute: RouteRecordRaw = { |
|||
component: () => import('#/views/_core/fallback/not-found.vue'), |
|||
meta: { |
|||
hideInBreadcrumb: true, |
|||
hideInMenu: true, |
|||
hideInTab: true, |
|||
title: '404', |
|||
}, |
|||
name: 'FallbackNotFound', |
|||
path: '/:path(.*)*', |
|||
}; |
|||
|
|||
/** 基本路由,这些路由是必须存在的 */ |
|||
const coreRoutes: RouteRecordRaw[] = [ |
|||
/** |
|||
* 根路由 |
|||
* 使用基础布局,作为所有页面的父级容器,子级就不必配置BasicLayout。 |
|||
* 此路由必须存在,且不应修改 |
|||
*/ |
|||
{ |
|||
component: BasicLayout, |
|||
meta: { |
|||
hideInBreadcrumb: true, |
|||
title: 'Root', |
|||
}, |
|||
name: 'Root', |
|||
path: '/', |
|||
redirect: preferences.app.defaultHomePath, |
|||
children: [], |
|||
}, |
|||
{ |
|||
component: AuthPageLayout, |
|||
meta: { |
|||
hideInTab: true, |
|||
title: 'Authentication', |
|||
}, |
|||
name: 'Authentication', |
|||
path: '/auth', |
|||
redirect: LOGIN_PATH, |
|||
children: [ |
|||
{ |
|||
name: 'Login', |
|||
path: 'login', |
|||
component: () => import('#/views/_core/authentication/login.vue'), |
|||
meta: { |
|||
title: $t('page.auth.login'), |
|||
}, |
|||
}, |
|||
{ |
|||
name: 'CodeLogin', |
|||
path: 'code-login', |
|||
component: () => import('#/views/_core/authentication/code-login.vue'), |
|||
meta: { |
|||
title: $t('page.auth.codeLogin'), |
|||
}, |
|||
}, |
|||
{ |
|||
name: 'QrCodeLogin', |
|||
path: 'qrcode-login', |
|||
component: () => |
|||
import('#/views/_core/authentication/qrcode-login.vue'), |
|||
meta: { |
|||
title: $t('page.auth.qrcodeLogin'), |
|||
}, |
|||
}, |
|||
{ |
|||
name: 'ForgetPassword', |
|||
path: 'forget-password', |
|||
component: () => |
|||
import('#/views/_core/authentication/forget-password.vue'), |
|||
meta: { |
|||
title: $t('page.auth.forgetPassword'), |
|||
}, |
|||
}, |
|||
{ |
|||
name: 'Register', |
|||
path: 'register', |
|||
component: () => import('#/views/_core/authentication/register.vue'), |
|||
meta: { |
|||
title: $t('page.auth.register'), |
|||
}, |
|||
}, |
|||
], |
|||
}, |
|||
]; |
|||
|
|||
export { coreRoutes, fallbackNotFoundRoute }; |
|||
@ -0,0 +1,37 @@ |
|||
import type { RouteRecordRaw } from 'vue-router'; |
|||
|
|||
import { mergeRouteModules, traverseTreeValues } from '@vben/utils'; |
|||
|
|||
import { coreRoutes, fallbackNotFoundRoute } from './core'; |
|||
|
|||
const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', { |
|||
eager: true, |
|||
}); |
|||
|
|||
// 有需要可以自行打开注释,并创建文件夹
|
|||
// const externalRouteFiles = import.meta.glob('./external/**/*.ts', { eager: true });
|
|||
// const staticRouteFiles = import.meta.glob('./static/**/*.ts', { eager: true });
|
|||
|
|||
/** 动态路由 */ |
|||
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles); |
|||
|
|||
/** 外部路由列表,访问这些页面可以不需要Layout,可能用于内嵌在别的系统(不会显示在菜单中) */ |
|||
// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
|
|||
// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
|
|||
const staticRoutes: RouteRecordRaw[] = []; |
|||
const externalRoutes: RouteRecordRaw[] = []; |
|||
|
|||
/** 路由列表,由基本路由、外部路由和404兜底路由组成 |
|||
* 无需走权限验证(会一直显示在菜单中) */ |
|||
const routes: RouteRecordRaw[] = [ |
|||
...coreRoutes, |
|||
...externalRoutes, |
|||
fallbackNotFoundRoute, |
|||
]; |
|||
|
|||
/** 基本路由列表,这些路由不需要进入权限拦截 */ |
|||
const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name); |
|||
|
|||
/** 有权限校验的路由列表,包含动态路由和静态路由 */ |
|||
const accessRoutes = [...dynamicRoutes, ...staticRoutes]; |
|||
export { accessRoutes, coreRouteNames, routes }; |
|||
@ -0,0 +1,38 @@ |
|||
import type { RouteRecordRaw } from 'vue-router'; |
|||
|
|||
import { $t } from '#/locales'; |
|||
|
|||
const routes: RouteRecordRaw[] = [ |
|||
{ |
|||
meta: { |
|||
icon: 'lucide:layout-dashboard', |
|||
order: -1, |
|||
title: $t('page.dashboard.title'), |
|||
}, |
|||
name: 'Dashboard', |
|||
path: '/dashboard', |
|||
children: [ |
|||
{ |
|||
name: 'Analytics', |
|||
path: '/analytics', |
|||
component: () => import('#/views/dashboard/analytics/index.vue'), |
|||
meta: { |
|||
affixTab: true, |
|||
icon: 'lucide:area-chart', |
|||
title: $t('page.dashboard.analytics'), |
|||
}, |
|||
}, |
|||
{ |
|||
name: 'Workspace', |
|||
path: '/workspace', |
|||
component: () => import('#/views/dashboard/workspace/index.vue'), |
|||
meta: { |
|||
icon: 'carbon:workspace', |
|||
title: $t('page.dashboard.workspace'), |
|||
}, |
|||
}, |
|||
], |
|||
}, |
|||
]; |
|||
|
|||
export default routes; |
|||
@ -0,0 +1,28 @@ |
|||
import type { RouteRecordRaw } from 'vue-router'; |
|||
|
|||
import { $t } from '#/locales'; |
|||
|
|||
const routes: RouteRecordRaw[] = [ |
|||
{ |
|||
meta: { |
|||
icon: 'ic:baseline-view-in-ar', |
|||
keepAlive: true, |
|||
order: 1000, |
|||
title: $t('demos.title'), |
|||
}, |
|||
name: 'Demos', |
|||
path: '/demos', |
|||
children: [ |
|||
{ |
|||
meta: { |
|||
title: $t('demos.antd'), |
|||
}, |
|||
name: 'AntDesignDemos', |
|||
path: '/demos/ant-design', |
|||
component: () => import('#/views/demos/antd/index.vue'), |
|||
}, |
|||
], |
|||
}, |
|||
]; |
|||
|
|||
export default routes; |
|||
@ -0,0 +1,31 @@ |
|||
/** |
|||
* 自动生成路由 |
|||
*/ |
|||
|
|||
import type {RouteRecordRaw} from 'vue-router'; |
|||
|
|||
const routes: RouteRecordRaw[] = [ |
|||
|
|||
{ |
|||
path: '/user', |
|||
name: '模块', |
|||
meta: { |
|||
icon: 'ic:baseline-view-in-ar', |
|||
order: 1000, |
|||
keepAlive: true, |
|||
title: '', |
|||
}, |
|||
children: [ |
|||
{ |
|||
meta: { |
|||
title: "列表", |
|||
}, |
|||
name: 'userList', |
|||
path: '/user', |
|||
component: () => import('#/views/user/index.vue'), |
|||
}, |
|||
], |
|||
} |
|||
]; |
|||
|
|||
export default routes; |
|||
@ -0,0 +1,116 @@ |
|||
import type { RouteRecordRaw } from 'vue-router'; |
|||
|
|||
import { |
|||
VBEN_ANTDV_NEXT_PREVIEW_URL, |
|||
VBEN_DOC_URL, |
|||
VBEN_ELE_PREVIEW_URL, |
|||
VBEN_GITHUB_URL, |
|||
VBEN_LOGO_URL, |
|||
VBEN_NAIVE_PREVIEW_URL, |
|||
VBEN_TD_PREVIEW_URL, |
|||
} from '@vben/constants'; |
|||
import { SvgAntdvNextLogoIcon, SvgTDesignIcon } from '@vben/icons'; |
|||
|
|||
import { IFrameView } from '#/layouts'; |
|||
import { $t } from '#/locales'; |
|||
|
|||
const routes: RouteRecordRaw[] = [ |
|||
{ |
|||
meta: { |
|||
badgeType: 'dot', |
|||
icon: VBEN_LOGO_URL, |
|||
order: 9998, |
|||
title: $t('demos.vben.title'), |
|||
}, |
|||
name: 'VbenProject', |
|||
path: '/vben-admin', |
|||
children: [ |
|||
{ |
|||
name: 'VbenDocument', |
|||
path: '/vben-admin/document', |
|||
component: IFrameView, |
|||
meta: { |
|||
icon: 'lucide:book-open-text', |
|||
link: VBEN_DOC_URL, |
|||
title: $t('demos.vben.document'), |
|||
}, |
|||
}, |
|||
{ |
|||
name: 'VbenGithub', |
|||
path: '/vben-admin/github', |
|||
component: IFrameView, |
|||
meta: { |
|||
icon: 'mdi:github', |
|||
link: VBEN_GITHUB_URL, |
|||
title: 'Github', |
|||
}, |
|||
}, |
|||
{ |
|||
name: 'VbenAntdVNext', |
|||
path: '/vben-admin/antdv-next', |
|||
component: IFrameView, |
|||
meta: { |
|||
badgeType: 'dot', |
|||
icon: SvgAntdvNextLogoIcon, |
|||
link: VBEN_ANTDV_NEXT_PREVIEW_URL, |
|||
title: $t('demos.vben.antdv-next'), |
|||
}, |
|||
}, |
|||
{ |
|||
name: 'VbenNaive', |
|||
path: '/vben-admin/naive', |
|||
component: IFrameView, |
|||
meta: { |
|||
badgeType: 'dot', |
|||
icon: 'logos:naiveui', |
|||
link: VBEN_NAIVE_PREVIEW_URL, |
|||
title: $t('demos.vben.naive-ui'), |
|||
}, |
|||
}, |
|||
{ |
|||
name: 'VbenTDesign', |
|||
path: '/vben-admin/tdesign', |
|||
component: IFrameView, |
|||
meta: { |
|||
badgeType: 'dot', |
|||
icon: SvgTDesignIcon, |
|||
link: VBEN_TD_PREVIEW_URL, |
|||
title: $t('demos.vben.tdesign'), |
|||
}, |
|||
}, |
|||
{ |
|||
name: 'VbenElementPlus', |
|||
path: '/vben-admin/ele', |
|||
component: IFrameView, |
|||
meta: { |
|||
badgeType: 'dot', |
|||
icon: 'logos:element', |
|||
link: VBEN_ELE_PREVIEW_URL, |
|||
title: $t('demos.vben.element-plus'), |
|||
}, |
|||
}, |
|||
], |
|||
}, |
|||
{ |
|||
name: 'VbenAbout', |
|||
path: '/vben-admin/about', |
|||
component: () => import('#/views/_core/about/index.vue'), |
|||
meta: { |
|||
icon: 'lucide:copyright', |
|||
title: $t('demos.vben.about'), |
|||
order: 9999, |
|||
}, |
|||
}, |
|||
{ |
|||
name: 'Profile', |
|||
path: '/profile', |
|||
component: () => import('#/views/_core/profile/index.vue'), |
|||
meta: { |
|||
icon: 'lucide:user', |
|||
hideInMenu: true, |
|||
title: $t('page.auth.profile'), |
|||
}, |
|||
}, |
|||
]; |
|||
|
|||
export default routes; |
|||
@ -0,0 +1,118 @@ |
|||
import type { Recordable, UserInfo } from '@vben/types'; |
|||
|
|||
import { ref } from 'vue'; |
|||
import { useRouter } from 'vue-router'; |
|||
|
|||
import { LOGIN_PATH } from '@vben/constants'; |
|||
import { preferences } from '@vben/preferences'; |
|||
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores'; |
|||
|
|||
import { notification } from 'ant-design-vue'; |
|||
import { defineStore } from 'pinia'; |
|||
|
|||
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api'; |
|||
import { $t } from '#/locales'; |
|||
|
|||
export const useAuthStore = defineStore('auth', () => { |
|||
const accessStore = useAccessStore(); |
|||
const userStore = useUserStore(); |
|||
const router = useRouter(); |
|||
|
|||
const loginLoading = ref(false); |
|||
|
|||
/** |
|||
* 异步处理登录操作 |
|||
* Asynchronously handle the login process |
|||
* @param params 登录表单数据 |
|||
*/ |
|||
async function authLogin( |
|||
params: Recordable<any>, |
|||
onSuccess?: () => Promise<void> | void, |
|||
) { |
|||
// 异步处理用户登录操作并获取 accessToken
|
|||
let userInfo: null | UserInfo = null; |
|||
try { |
|||
loginLoading.value = true; |
|||
const { accessToken } = await loginApi(params); |
|||
|
|||
// 如果成功获取到 accessToken
|
|||
if (accessToken) { |
|||
accessStore.setAccessToken(accessToken); |
|||
|
|||
// 获取用户信息并存储到 accessStore 中
|
|||
const [fetchUserInfoResult, accessCodes] = await Promise.all([ |
|||
fetchUserInfo(), |
|||
getAccessCodesApi(), |
|||
]); |
|||
|
|||
userInfo = fetchUserInfoResult; |
|||
|
|||
userStore.setUserInfo(userInfo); |
|||
accessStore.setAccessCodes(accessCodes); |
|||
|
|||
if (accessStore.loginExpired) { |
|||
accessStore.setLoginExpired(false); |
|||
} else { |
|||
onSuccess |
|||
? await onSuccess?.() |
|||
: await router.push( |
|||
userInfo.homePath || preferences.app.defaultHomePath, |
|||
); |
|||
} |
|||
|
|||
if (userInfo?.realName) { |
|||
notification.success({ |
|||
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`, |
|||
duration: 3, |
|||
message: $t('authentication.loginSuccess'), |
|||
}); |
|||
} |
|||
} |
|||
} finally { |
|||
loginLoading.value = false; |
|||
} |
|||
|
|||
return { |
|||
userInfo, |
|||
}; |
|||
} |
|||
|
|||
async function logout(redirect: boolean = true) { |
|||
try { |
|||
await logoutApi(); |
|||
} catch { |
|||
// 不做任何处理
|
|||
} |
|||
resetAllStores(); |
|||
accessStore.setLoginExpired(false); |
|||
|
|||
// 回登录页带上当前路由地址
|
|||
await router.replace({ |
|||
path: LOGIN_PATH, |
|||
query: redirect |
|||
? { |
|||
redirect: encodeURIComponent(router.currentRoute.value.fullPath), |
|||
} |
|||
: {}, |
|||
}); |
|||
} |
|||
|
|||
async function fetchUserInfo() { |
|||
let userInfo: null | UserInfo = null; |
|||
userInfo = await getUserInfoApi(); |
|||
userStore.setUserInfo(userInfo); |
|||
return userInfo; |
|||
} |
|||
|
|||
function $reset() { |
|||
loginLoading.value = false; |
|||
} |
|||
|
|||
return { |
|||
$reset, |
|||
authLogin, |
|||
fetchUserInfo, |
|||
loginLoading, |
|||
logout, |
|||
}; |
|||
}); |
|||
@ -0,0 +1 @@ |
|||
export * from './auth'; |
|||
@ -0,0 +1,3 @@ |
|||
# \_core |
|||
|
|||
此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。 |
|||
@ -0,0 +1,9 @@ |
|||
<script lang="ts" setup> |
|||
import { About } from '@vben/common-ui'; |
|||
|
|||
defineOptions({ name: 'About' }); |
|||
</script> |
|||
|
|||
<template> |
|||
<About /> |
|||
</template> |
|||
@ -0,0 +1,69 @@ |
|||
<script lang="ts" setup> |
|||
import type { VbenFormSchema } from '@vben/common-ui'; |
|||
import type { Recordable } from '@vben/types'; |
|||
|
|||
import { computed, ref } from 'vue'; |
|||
|
|||
import { AuthenticationCodeLogin, z } from '@vben/common-ui'; |
|||
import { $t } from '@vben/locales'; |
|||
|
|||
defineOptions({ name: 'CodeLogin' }); |
|||
|
|||
const loading = ref(false); |
|||
const CODE_LENGTH = 6; |
|||
|
|||
const formSchema = computed((): VbenFormSchema[] => { |
|||
return [ |
|||
{ |
|||
component: 'VbenInput', |
|||
componentProps: { |
|||
placeholder: $t('authentication.mobile'), |
|||
}, |
|||
fieldName: 'phoneNumber', |
|||
label: $t('authentication.mobile'), |
|||
rules: z |
|||
.string() |
|||
.min(1, { message: $t('authentication.mobileTip') }) |
|||
.refine((v) => /^\d{11}$/.test(v), { |
|||
message: $t('authentication.mobileErrortip'), |
|||
}), |
|||
}, |
|||
{ |
|||
component: 'VbenPinInput', |
|||
componentProps: { |
|||
codeLength: CODE_LENGTH, |
|||
createText: (countdown: number) => { |
|||
const text = |
|||
countdown > 0 |
|||
? $t('authentication.sendText', [countdown]) |
|||
: $t('authentication.sendCode'); |
|||
return text; |
|||
}, |
|||
placeholder: $t('authentication.code'), |
|||
}, |
|||
fieldName: 'code', |
|||
label: $t('authentication.code'), |
|||
rules: z.string().length(CODE_LENGTH, { |
|||
message: $t('authentication.codeTip', [CODE_LENGTH]), |
|||
}), |
|||
}, |
|||
]; |
|||
}); |
|||
/** |
|||
* 异步处理登录操作 |
|||
* Asynchronously handle the login process |
|||
* @param values 登录表单数据 |
|||
*/ |
|||
async function handleLogin(values: Recordable<any>) { |
|||
// eslint-disable-next-line no-console |
|||
console.log(values); |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<AuthenticationCodeLogin |
|||
:form-schema="formSchema" |
|||
:loading="loading" |
|||
@submit="handleLogin" |
|||
/> |
|||
</template> |
|||
@ -0,0 +1,43 @@ |
|||
<script lang="ts" setup> |
|||
import type { VbenFormSchema } from '@vben/common-ui'; |
|||
import type { Recordable } from '@vben/types'; |
|||
|
|||
import { computed, ref } from 'vue'; |
|||
|
|||
import { AuthenticationForgetPassword, z } from '@vben/common-ui'; |
|||
import { $t } from '@vben/locales'; |
|||
|
|||
defineOptions({ name: 'ForgetPassword' }); |
|||
|
|||
const loading = ref(false); |
|||
|
|||
const formSchema = computed((): VbenFormSchema[] => { |
|||
return [ |
|||
{ |
|||
component: 'VbenInput', |
|||
componentProps: { |
|||
placeholder: 'example@example.com', |
|||
}, |
|||
fieldName: 'email', |
|||
label: $t('authentication.email'), |
|||
rules: z |
|||
.string() |
|||
.min(1, { message: $t('authentication.emailTip') }) |
|||
.email($t('authentication.emailValidErrorTip')), |
|||
}, |
|||
]; |
|||
}); |
|||
|
|||
function handleSubmit(value: Recordable<any>) { |
|||
// eslint-disable-next-line no-console |
|||
console.log('reset email:', value); |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<AuthenticationForgetPassword |
|||
:form-schema="formSchema" |
|||
:loading="loading" |
|||
@submit="handleSubmit" |
|||
/> |
|||
</template> |
|||
@ -0,0 +1,98 @@ |
|||
<script lang="ts" setup> |
|||
import type { VbenFormSchema } from '@vben/common-ui'; |
|||
import type { BasicOption } from '@vben/types'; |
|||
|
|||
import { computed, markRaw } from 'vue'; |
|||
|
|||
import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui'; |
|||
import { $t } from '@vben/locales'; |
|||
|
|||
import { useAuthStore } from '#/store'; |
|||
|
|||
defineOptions({ name: 'Login' }); |
|||
|
|||
const authStore = useAuthStore(); |
|||
|
|||
const MOCK_USER_OPTIONS: BasicOption[] = [ |
|||
{ |
|||
label: 'Super', |
|||
value: 'vben', |
|||
}, |
|||
{ |
|||
label: 'Admin', |
|||
value: 'admin', |
|||
}, |
|||
{ |
|||
label: 'User', |
|||
value: 'jack', |
|||
}, |
|||
]; |
|||
|
|||
const formSchema = computed((): VbenFormSchema[] => { |
|||
return [ |
|||
{ |
|||
component: 'VbenSelect', |
|||
componentProps: { |
|||
options: MOCK_USER_OPTIONS, |
|||
placeholder: $t('authentication.selectAccount'), |
|||
}, |
|||
fieldName: 'selectAccount', |
|||
label: $t('authentication.selectAccount'), |
|||
rules: z |
|||
.string() |
|||
.min(1, { message: $t('authentication.selectAccount') }) |
|||
.optional() |
|||
.default('vben'), |
|||
}, |
|||
{ |
|||
component: 'VbenInput', |
|||
componentProps: { |
|||
placeholder: $t('authentication.usernameTip'), |
|||
}, |
|||
dependencies: { |
|||
trigger(values, form) { |
|||
if (values.selectAccount) { |
|||
const findUser = MOCK_USER_OPTIONS.find( |
|||
(item) => item.value === values.selectAccount, |
|||
); |
|||
if (findUser) { |
|||
form.setValues({ |
|||
password: '123456', |
|||
username: findUser.value, |
|||
}); |
|||
} |
|||
} |
|||
}, |
|||
triggerFields: ['selectAccount'], |
|||
}, |
|||
fieldName: 'username', |
|||
label: $t('authentication.username'), |
|||
rules: z.string().min(1, { message: $t('authentication.usernameTip') }), |
|||
}, |
|||
{ |
|||
component: 'VbenInputPassword', |
|||
componentProps: { |
|||
placeholder: $t('authentication.password'), |
|||
}, |
|||
fieldName: 'password', |
|||
label: $t('authentication.password'), |
|||
rules: z.string().min(1, { message: $t('authentication.passwordTip') }), |
|||
}, |
|||
{ |
|||
component: markRaw(SliderCaptcha), |
|||
fieldName: 'captcha', |
|||
rules: z.boolean().refine((value) => value, { |
|||
message: $t('authentication.verifyRequiredTip'), |
|||
}), |
|||
}, |
|||
]; |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<AuthenticationLogin |
|||
:form-schema="formSchema" |
|||
:loading="authStore.loginLoading" |
|||
@submit="authStore.authLogin" |
|||
/> |
|||
</template> |
|||
@ -0,0 +1,10 @@ |
|||
<script lang="ts" setup> |
|||
import { AuthenticationQrCodeLogin } from '@vben/common-ui'; |
|||
import { LOGIN_PATH } from '@vben/constants'; |
|||
|
|||
defineOptions({ name: 'QrCodeLogin' }); |
|||
</script> |
|||
|
|||
<template> |
|||
<AuthenticationQrCodeLogin :login-path="LOGIN_PATH" /> |
|||
</template> |
|||
@ -0,0 +1,96 @@ |
|||
<script lang="ts" setup> |
|||
import type { VbenFormSchema } from '@vben/common-ui'; |
|||
import type { Recordable } from '@vben/types'; |
|||
|
|||
import { computed, h, ref } from 'vue'; |
|||
|
|||
import { AuthenticationRegister, z } from '@vben/common-ui'; |
|||
import { $t } from '@vben/locales'; |
|||
|
|||
defineOptions({ name: 'Register' }); |
|||
|
|||
const loading = ref(false); |
|||
|
|||
const formSchema = computed((): VbenFormSchema[] => { |
|||
return [ |
|||
{ |
|||
component: 'VbenInput', |
|||
componentProps: { |
|||
placeholder: $t('authentication.usernameTip'), |
|||
}, |
|||
fieldName: 'username', |
|||
label: $t('authentication.username'), |
|||
rules: z.string().min(1, { message: $t('authentication.usernameTip') }), |
|||
}, |
|||
{ |
|||
component: 'VbenInputPassword', |
|||
componentProps: { |
|||
passwordStrength: true, |
|||
placeholder: $t('authentication.password'), |
|||
}, |
|||
fieldName: 'password', |
|||
label: $t('authentication.password'), |
|||
renderComponentContent() { |
|||
return { |
|||
strengthText: () => $t('authentication.passwordStrength'), |
|||
}; |
|||
}, |
|||
rules: z.string().min(1, { message: $t('authentication.passwordTip') }), |
|||
}, |
|||
{ |
|||
component: 'VbenInputPassword', |
|||
componentProps: { |
|||
placeholder: $t('authentication.confirmPassword'), |
|||
}, |
|||
dependencies: { |
|||
rules(values) { |
|||
const { password } = values; |
|||
return z |
|||
.string({ required_error: $t('authentication.passwordTip') }) |
|||
.min(1, { message: $t('authentication.passwordTip') }) |
|||
.refine((value) => value === password, { |
|||
message: $t('authentication.confirmPasswordTip'), |
|||
}); |
|||
}, |
|||
triggerFields: ['password'], |
|||
}, |
|||
fieldName: 'confirmPassword', |
|||
label: $t('authentication.confirmPassword'), |
|||
}, |
|||
{ |
|||
component: 'VbenCheckbox', |
|||
fieldName: 'agreePolicy', |
|||
renderComponentContent: () => ({ |
|||
default: () => |
|||
h('span', [ |
|||
$t('authentication.agree'), |
|||
h( |
|||
'a', |
|||
{ |
|||
class: 'vben-link ml-1 ', |
|||
href: '', |
|||
}, |
|||
`${$t('authentication.privacyPolicy')} & ${$t('authentication.terms')}`, |
|||
), |
|||
]), |
|||
}), |
|||
rules: z.boolean().refine((value) => !!value, { |
|||
message: $t('authentication.agreeTip'), |
|||
}), |
|||
}, |
|||
]; |
|||
}); |
|||
|
|||
function handleSubmit(value: Recordable<any>) { |
|||
// eslint-disable-next-line no-console |
|||
console.log('register submit:', value); |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<AuthenticationRegister |
|||
:form-schema="formSchema" |
|||
:loading="loading" |
|||
@submit="handleSubmit" |
|||
/> |
|||
</template> |
|||
@ -0,0 +1,7 @@ |
|||
<script lang="ts" setup> |
|||
import { Fallback } from '@vben/common-ui'; |
|||
</script> |
|||
|
|||
<template> |
|||
<Fallback status="coming-soon" /> |
|||
</template> |
|||
@ -0,0 +1,9 @@ |
|||
<script lang="ts" setup> |
|||
import { Fallback } from '@vben/common-ui'; |
|||
|
|||
defineOptions({ name: 'Fallback403Demo' }); |
|||
</script> |
|||
|
|||
<template> |
|||
<Fallback status="403" /> |
|||
</template> |
|||
@ -0,0 +1,9 @@ |
|||
<script lang="ts" setup> |
|||
import { Fallback } from '@vben/common-ui'; |
|||
|
|||
defineOptions({ name: 'Fallback500Demo' }); |
|||
</script> |
|||
|
|||
<template> |
|||
<Fallback status="500" /> |
|||
</template> |
|||
@ -0,0 +1,9 @@ |
|||
<script lang="ts" setup> |
|||
import { Fallback } from '@vben/common-ui'; |
|||
|
|||
defineOptions({ name: 'Fallback404Demo' }); |
|||
</script> |
|||
|
|||
<template> |
|||
<Fallback status="404" /> |
|||
</template> |
|||
@ -0,0 +1,9 @@ |
|||
<script lang="ts" setup> |
|||
import { Fallback } from '@vben/common-ui'; |
|||
|
|||
defineOptions({ name: 'FallbackOfflineDemo' }); |
|||
</script> |
|||
|
|||
<template> |
|||
<Fallback status="offline" /> |
|||
</template> |
|||
@ -0,0 +1,65 @@ |
|||
<script setup lang="ts"> |
|||
import type { BasicOption } from '@vben/types'; |
|||
|
|||
import type { VbenFormSchema } from '#/adapter/form'; |
|||
|
|||
import { computed, onMounted, ref } from 'vue'; |
|||
|
|||
import { ProfileBaseSetting } from '@vben/common-ui'; |
|||
|
|||
import { getUserInfoApi } from '#/api'; |
|||
|
|||
const profileBaseSettingRef = ref(); |
|||
|
|||
const MOCK_ROLES_OPTIONS: BasicOption[] = [ |
|||
{ |
|||
label: '管理员', |
|||
value: 'super', |
|||
}, |
|||
{ |
|||
label: '用户', |
|||
value: 'user', |
|||
}, |
|||
{ |
|||
label: '测试', |
|||
value: 'test', |
|||
}, |
|||
]; |
|||
|
|||
const formSchema = computed((): VbenFormSchema[] => { |
|||
return [ |
|||
{ |
|||
fieldName: 'realName', |
|||
component: 'Input', |
|||
label: '姓名', |
|||
}, |
|||
{ |
|||
fieldName: 'username', |
|||
component: 'Input', |
|||
label: '用户名', |
|||
}, |
|||
{ |
|||
fieldName: 'roles', |
|||
component: 'Select', |
|||
componentProps: { |
|||
mode: 'tags', |
|||
options: MOCK_ROLES_OPTIONS, |
|||
}, |
|||
label: '角色', |
|||
}, |
|||
{ |
|||
fieldName: 'introduction', |
|||
component: 'Textarea', |
|||
label: '个人简介', |
|||
}, |
|||
]; |
|||
}); |
|||
|
|||
onMounted(async () => { |
|||
const data = await getUserInfoApi(); |
|||
profileBaseSettingRef.value.getFormApi().setValues(data); |
|||
}); |
|||
</script> |
|||
<template> |
|||
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" /> |
|||
</template> |
|||
@ -0,0 +1,49 @@ |
|||
<script setup lang="ts"> |
|||
import { ref } from 'vue'; |
|||
|
|||
import { Profile } from '@vben/common-ui'; |
|||
import { useUserStore } from '@vben/stores'; |
|||
|
|||
import ProfileBase from './base-setting.vue'; |
|||
import ProfileNotificationSetting from './notification-setting.vue'; |
|||
import ProfilePasswordSetting from './password-setting.vue'; |
|||
import ProfileSecuritySetting from './security-setting.vue'; |
|||
|
|||
const userStore = useUserStore(); |
|||
|
|||
const tabsValue = ref<string>('basic'); |
|||
|
|||
const tabs = ref([ |
|||
{ |
|||
label: '基本设置', |
|||
value: 'basic', |
|||
}, |
|||
{ |
|||
label: '安全设置', |
|||
value: 'security', |
|||
}, |
|||
{ |
|||
label: '修改密码', |
|||
value: 'password', |
|||
}, |
|||
{ |
|||
label: '新消息提醒', |
|||
value: 'notice', |
|||
}, |
|||
]); |
|||
</script> |
|||
<template> |
|||
<Profile |
|||
v-model:model-value="tabsValue" |
|||
title="个人中心" |
|||
:user-info="userStore.userInfo" |
|||
:tabs="tabs" |
|||
> |
|||
<template #content> |
|||
<ProfileBase v-if="tabsValue === 'basic'" /> |
|||
<ProfileSecuritySetting v-if="tabsValue === 'security'" /> |
|||
<ProfilePasswordSetting v-if="tabsValue === 'password'" /> |
|||
<ProfileNotificationSetting v-if="tabsValue === 'notice'" /> |
|||
</template> |
|||
</Profile> |
|||
</template> |
|||
@ -0,0 +1,31 @@ |
|||
<script setup lang="ts"> |
|||
import { computed } from 'vue'; |
|||
|
|||
import { ProfileNotificationSetting } from '@vben/common-ui'; |
|||
|
|||
const formSchema = computed(() => { |
|||
return [ |
|||
{ |
|||
value: true, |
|||
fieldName: 'accountPassword', |
|||
label: '账户密码', |
|||
description: '其他用户的消息将以站内信的形式通知', |
|||
}, |
|||
{ |
|||
value: true, |
|||
fieldName: 'systemMessage', |
|||
label: '系统消息', |
|||
description: '系统消息将以站内信的形式通知', |
|||
}, |
|||
{ |
|||
value: true, |
|||
fieldName: 'todoTask', |
|||
label: '待办任务', |
|||
description: '待办任务将以站内信的形式通知', |
|||
}, |
|||
]; |
|||
}); |
|||
</script> |
|||
<template> |
|||
<ProfileNotificationSetting :form-schema="formSchema" /> |
|||
</template> |
|||
@ -0,0 +1,63 @@ |
|||
<script setup lang="ts"> |
|||
import type { VbenFormSchema } from '#/adapter/form'; |
|||
|
|||
import { computed } from 'vue'; |
|||
|
|||
import { ProfilePasswordSetting, z } from '@vben/common-ui'; |
|||
|
|||
import { message } from 'ant-design-vue'; |
|||
|
|||
const formSchema = computed((): VbenFormSchema[] => { |
|||
return [ |
|||
{ |
|||
fieldName: 'oldPassword', |
|||
label: '旧密码', |
|||
component: 'VbenInputPassword', |
|||
componentProps: { |
|||
placeholder: '请输入旧密码', |
|||
}, |
|||
}, |
|||
{ |
|||
fieldName: 'newPassword', |
|||
label: '新密码', |
|||
component: 'VbenInputPassword', |
|||
componentProps: { |
|||
passwordStrength: true, |
|||
placeholder: '请输入新密码', |
|||
}, |
|||
}, |
|||
{ |
|||
fieldName: 'confirmPassword', |
|||
label: '确认密码', |
|||
component: 'VbenInputPassword', |
|||
componentProps: { |
|||
passwordStrength: true, |
|||
placeholder: '请再次输入新密码', |
|||
}, |
|||
dependencies: { |
|||
rules(values) { |
|||
const { newPassword } = values; |
|||
return z |
|||
.string({ required_error: '请再次输入新密码' }) |
|||
.min(1, { message: '请再次输入新密码' }) |
|||
.refine((value) => value === newPassword, { |
|||
message: '两次输入的密码不一致', |
|||
}); |
|||
}, |
|||
triggerFields: ['newPassword'], |
|||
}, |
|||
}, |
|||
]; |
|||
}); |
|||
|
|||
function handleSubmit() { |
|||
message.success('密码修改成功'); |
|||
} |
|||
</script> |
|||
<template> |
|||
<ProfilePasswordSetting |
|||
class="w-1/3" |
|||
:form-schema="formSchema" |
|||
@submit="handleSubmit" |
|||
/> |
|||
</template> |
|||
@ -0,0 +1,43 @@ |
|||
<script setup lang="ts"> |
|||
import { computed } from 'vue'; |
|||
|
|||
import { ProfileSecuritySetting } from '@vben/common-ui'; |
|||
|
|||
const formSchema = computed(() => { |
|||
return [ |
|||
{ |
|||
value: true, |
|||
fieldName: 'accountPassword', |
|||
label: '账户密码', |
|||
description: '当前密码强度:强', |
|||
}, |
|||
{ |
|||
value: true, |
|||
fieldName: 'securityPhone', |
|||
label: '密保手机', |
|||
description: '已绑定手机:138****8293', |
|||
}, |
|||
{ |
|||
value: true, |
|||
fieldName: 'securityQuestion', |
|||
label: '密保问题', |
|||
description: '未设置密保问题,密保问题可有效保护账户安全', |
|||
}, |
|||
{ |
|||
value: true, |
|||
fieldName: 'securityEmail', |
|||
label: '备用邮箱', |
|||
description: '已绑定邮箱:ant***sign.com', |
|||
}, |
|||
{ |
|||
value: false, |
|||
fieldName: 'securityMfa', |
|||
label: 'MFA 设备', |
|||
description: '未绑定 MFA 设备,绑定后,可以进行二次确认', |
|||
}, |
|||
]; |
|||
}); |
|||
</script> |
|||
<template> |
|||
<ProfileSecuritySetting :form-schema="formSchema" /> |
|||
</template> |
|||
@ -0,0 +1,98 @@ |
|||
<script lang="ts" setup> |
|||
import type { EchartsUIType } from '@vben/plugins/echarts'; |
|||
|
|||
import { onMounted, ref } from 'vue'; |
|||
|
|||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; |
|||
|
|||
const chartRef = ref<EchartsUIType>(); |
|||
const { renderEcharts } = useEcharts(chartRef); |
|||
|
|||
onMounted(() => { |
|||
renderEcharts({ |
|||
grid: { |
|||
bottom: 0, |
|||
containLabel: true, |
|||
left: '1%', |
|||
right: '1%', |
|||
top: '2 %', |
|||
}, |
|||
series: [ |
|||
{ |
|||
areaStyle: {}, |
|||
data: [ |
|||
111, 2000, 6000, 16_000, 33_333, 55_555, 64_000, 33_333, 18_000, |
|||
36_000, 70_000, 42_444, 23_222, 13_000, 8000, 4000, 1200, 333, 222, |
|||
111, |
|||
], |
|||
itemStyle: { |
|||
color: '#5ab1ef', |
|||
}, |
|||
smooth: true, |
|||
type: 'line', |
|||
}, |
|||
{ |
|||
areaStyle: {}, |
|||
data: [ |
|||
33, 66, 88, 333, 3333, 6200, 20_000, 3000, 1200, 13_000, 22_000, |
|||
11_000, 2221, 1201, 390, 198, 60, 30, 22, 11, |
|||
], |
|||
itemStyle: { |
|||
color: '#019680', |
|||
}, |
|||
smooth: true, |
|||
type: 'line', |
|||
}, |
|||
], |
|||
tooltip: { |
|||
axisPointer: { |
|||
lineStyle: { |
|||
color: '#019680', |
|||
width: 1, |
|||
}, |
|||
}, |
|||
trigger: 'axis', |
|||
}, |
|||
// xAxis: { |
|||
// axisTick: { |
|||
// show: false, |
|||
// }, |
|||
// boundaryGap: false, |
|||
// data: Array.from({ length: 18 }).map((_item, index) => `${index + 6}:00`), |
|||
// type: 'category', |
|||
// }, |
|||
xAxis: { |
|||
axisTick: { |
|||
show: false, |
|||
}, |
|||
boundaryGap: false, |
|||
data: Array.from({ length: 18 }).map((_item, index) => `${index + 6}:00`), |
|||
splitLine: { |
|||
lineStyle: { |
|||
type: 'solid', |
|||
width: 1, |
|||
}, |
|||
show: true, |
|||
}, |
|||
type: 'category', |
|||
}, |
|||
yAxis: [ |
|||
{ |
|||
axisTick: { |
|||
show: false, |
|||
}, |
|||
max: 80_000, |
|||
splitArea: { |
|||
show: true, |
|||
}, |
|||
splitNumber: 4, |
|||
type: 'value', |
|||
}, |
|||
], |
|||
}); |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<EchartsUI ref="chartRef" /> |
|||
</template> |
|||
@ -0,0 +1,82 @@ |
|||
<script lang="ts" setup> |
|||
import type { EchartsUIType } from '@vben/plugins/echarts'; |
|||
|
|||
import { onMounted, ref } from 'vue'; |
|||
|
|||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; |
|||
|
|||
const chartRef = ref<EchartsUIType>(); |
|||
const { renderEcharts } = useEcharts(chartRef); |
|||
|
|||
onMounted(() => { |
|||
renderEcharts({ |
|||
legend: { |
|||
bottom: 0, |
|||
data: ['访问', '趋势'], |
|||
}, |
|||
radar: { |
|||
indicator: [ |
|||
{ |
|||
name: '网页', |
|||
}, |
|||
{ |
|||
name: '移动端', |
|||
}, |
|||
{ |
|||
name: 'Ipad', |
|||
}, |
|||
{ |
|||
name: '客户端', |
|||
}, |
|||
{ |
|||
name: '第三方', |
|||
}, |
|||
{ |
|||
name: '其它', |
|||
}, |
|||
], |
|||
radius: '60%', |
|||
splitNumber: 8, |
|||
}, |
|||
series: [ |
|||
{ |
|||
areaStyle: { |
|||
opacity: 1, |
|||
shadowBlur: 0, |
|||
shadowColor: 'rgba(0,0,0,.2)', |
|||
shadowOffsetX: 0, |
|||
shadowOffsetY: 10, |
|||
}, |
|||
data: [ |
|||
{ |
|||
itemStyle: { |
|||
color: '#b6a2de', |
|||
}, |
|||
name: '访问', |
|||
value: [90, 50, 86, 40, 50, 20], |
|||
}, |
|||
{ |
|||
itemStyle: { |
|||
color: '#5ab1ef', |
|||
}, |
|||
name: '趋势', |
|||
value: [70, 75, 70, 76, 20, 85], |
|||
}, |
|||
], |
|||
itemStyle: { |
|||
// borderColor: '#fff', |
|||
borderRadius: 10, |
|||
borderWidth: 2, |
|||
}, |
|||
symbolSize: 0, |
|||
type: 'radar', |
|||
}, |
|||
], |
|||
tooltip: {}, |
|||
}); |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<EchartsUI ref="chartRef" /> |
|||
</template> |
|||
@ -0,0 +1,46 @@ |
|||
<script lang="ts" setup> |
|||
import type { EchartsUIType } from '@vben/plugins/echarts'; |
|||
|
|||
import { onMounted, ref } from 'vue'; |
|||
|
|||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; |
|||
|
|||
const chartRef = ref<EchartsUIType>(); |
|||
const { renderEcharts } = useEcharts(chartRef); |
|||
|
|||
onMounted(() => { |
|||
renderEcharts({ |
|||
series: [ |
|||
{ |
|||
animationDelay() { |
|||
return Math.random() * 400; |
|||
}, |
|||
animationEasing: 'exponentialInOut', |
|||
animationType: 'scale', |
|||
center: ['50%', '50%'], |
|||
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'], |
|||
data: [ |
|||
{ name: '外包', value: 500 }, |
|||
{ name: '定制', value: 310 }, |
|||
{ name: '技术支持', value: 274 }, |
|||
{ name: '远程', value: 400 }, |
|||
].toSorted((a, b) => { |
|||
return a.value - b.value; |
|||
}), |
|||
name: '商业占比', |
|||
radius: '80%', |
|||
roseType: 'radius', |
|||
type: 'pie', |
|||
}, |
|||
], |
|||
|
|||
tooltip: { |
|||
trigger: 'item', |
|||
}, |
|||
}); |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<EchartsUI ref="chartRef" /> |
|||
</template> |
|||
@ -0,0 +1,65 @@ |
|||
<script lang="ts" setup> |
|||
import type { EchartsUIType } from '@vben/plugins/echarts'; |
|||
|
|||
import { onMounted, ref } from 'vue'; |
|||
|
|||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; |
|||
|
|||
const chartRef = ref<EchartsUIType>(); |
|||
const { renderEcharts } = useEcharts(chartRef); |
|||
|
|||
onMounted(() => { |
|||
renderEcharts({ |
|||
legend: { |
|||
bottom: '2%', |
|||
left: 'center', |
|||
}, |
|||
series: [ |
|||
{ |
|||
animationDelay() { |
|||
return Math.random() * 100; |
|||
}, |
|||
animationEasing: 'exponentialInOut', |
|||
animationType: 'scale', |
|||
avoidLabelOverlap: false, |
|||
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'], |
|||
data: [ |
|||
{ name: '搜索引擎', value: 1048 }, |
|||
{ name: '直接访问', value: 735 }, |
|||
{ name: '邮件营销', value: 580 }, |
|||
{ name: '联盟广告', value: 484 }, |
|||
], |
|||
emphasis: { |
|||
label: { |
|||
fontSize: '12', |
|||
fontWeight: 'bold', |
|||
show: true, |
|||
}, |
|||
}, |
|||
itemStyle: { |
|||
// borderColor: '#fff', |
|||
borderRadius: 10, |
|||
borderWidth: 2, |
|||
}, |
|||
label: { |
|||
position: 'center', |
|||
show: false, |
|||
}, |
|||
labelLine: { |
|||
show: false, |
|||
}, |
|||
name: '访问来源', |
|||
radius: ['40%', '65%'], |
|||
type: 'pie', |
|||
}, |
|||
], |
|||
tooltip: { |
|||
trigger: 'item', |
|||
}, |
|||
}); |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<EchartsUI ref="chartRef" /> |
|||
</template> |
|||
@ -0,0 +1,55 @@ |
|||
<script lang="ts" setup> |
|||
import type { EchartsUIType } from '@vben/plugins/echarts'; |
|||
|
|||
import { onMounted, ref } from 'vue'; |
|||
|
|||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; |
|||
|
|||
const chartRef = ref<EchartsUIType>(); |
|||
const { renderEcharts } = useEcharts(chartRef); |
|||
|
|||
onMounted(() => { |
|||
renderEcharts({ |
|||
grid: { |
|||
bottom: 0, |
|||
containLabel: true, |
|||
left: '1%', |
|||
right: '1%', |
|||
top: '2 %', |
|||
}, |
|||
series: [ |
|||
{ |
|||
barMaxWidth: 80, |
|||
// color: '#4f69fd', |
|||
data: [ |
|||
3000, 2000, 3333, 5000, 3200, 4200, 3200, 2100, 3000, 5100, 6000, |
|||
3200, 4800, |
|||
], |
|||
type: 'bar', |
|||
}, |
|||
], |
|||
tooltip: { |
|||
axisPointer: { |
|||
lineStyle: { |
|||
// color: '#4f69fd', |
|||
width: 1, |
|||
}, |
|||
}, |
|||
trigger: 'axis', |
|||
}, |
|||
xAxis: { |
|||
data: Array.from({ length: 12 }).map((_item, index) => `${index + 1}月`), |
|||
type: 'category', |
|||
}, |
|||
yAxis: { |
|||
max: 8000, |
|||
splitNumber: 4, |
|||
type: 'value', |
|||
}, |
|||
}); |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<EchartsUI ref="chartRef" /> |
|||
</template> |
|||
@ -0,0 +1,90 @@ |
|||
<script lang="ts" setup> |
|||
import type { AnalysisOverviewItem } from '@vben/common-ui'; |
|||
import type { TabOption } from '@vben/types'; |
|||
|
|||
import { |
|||
AnalysisChartCard, |
|||
AnalysisChartsTabs, |
|||
AnalysisOverview, |
|||
} from '@vben/common-ui'; |
|||
import { |
|||
SvgBellIcon, |
|||
SvgCakeIcon, |
|||
SvgCardIcon, |
|||
SvgDownloadIcon, |
|||
} from '@vben/icons'; |
|||
|
|||
import AnalyticsTrends from './analytics-trends.vue'; |
|||
import AnalyticsVisitsData from './analytics-visits-data.vue'; |
|||
import AnalyticsVisitsSales from './analytics-visits-sales.vue'; |
|||
import AnalyticsVisitsSource from './analytics-visits-source.vue'; |
|||
import AnalyticsVisits from './analytics-visits.vue'; |
|||
|
|||
const overviewItems: AnalysisOverviewItem[] = [ |
|||
{ |
|||
icon: SvgCardIcon, |
|||
title: '用户量', |
|||
totalTitle: '总用户量', |
|||
totalValue: 120_000, |
|||
value: 2000, |
|||
}, |
|||
{ |
|||
icon: SvgCakeIcon, |
|||
title: '访问量', |
|||
totalTitle: '总访问量', |
|||
totalValue: 500_000, |
|||
value: 20_000, |
|||
}, |
|||
{ |
|||
icon: SvgDownloadIcon, |
|||
title: '下载量', |
|||
totalTitle: '总下载量', |
|||
totalValue: 120_000, |
|||
value: 8000, |
|||
}, |
|||
{ |
|||
icon: SvgBellIcon, |
|||
title: '使用量', |
|||
totalTitle: '总使用量', |
|||
totalValue: 50_000, |
|||
value: 5000, |
|||
}, |
|||
]; |
|||
|
|||
const chartTabs: TabOption[] = [ |
|||
{ |
|||
label: '流量趋势', |
|||
value: 'trends', |
|||
}, |
|||
{ |
|||
label: '月访问量', |
|||
value: 'visits', |
|||
}, |
|||
]; |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="p-5"> |
|||
<AnalysisOverview :items="overviewItems" /> |
|||
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5"> |
|||
<template #trends> |
|||
<AnalyticsTrends /> |
|||
</template> |
|||
<template #visits> |
|||
<AnalyticsVisits /> |
|||
</template> |
|||
</AnalysisChartsTabs> |
|||
|
|||
<div class="mt-5 w-full md:flex"> |
|||
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="访问数量"> |
|||
<AnalyticsVisitsData /> |
|||
</AnalysisChartCard> |
|||
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="访问来源"> |
|||
<AnalyticsVisitsSource /> |
|||
</AnalysisChartCard> |
|||
<AnalysisChartCard class="mt-5 md:mt-0 md:w-1/3" title="访问来源"> |
|||
<AnalyticsVisitsSales /> |
|||
</AnalysisChartCard> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
@ -0,0 +1,266 @@ |
|||
<script lang="ts" setup> |
|||
import type { |
|||
WorkbenchProjectItem, |
|||
WorkbenchQuickNavItem, |
|||
WorkbenchTodoItem, |
|||
WorkbenchTrendItem, |
|||
} from '@vben/common-ui'; |
|||
|
|||
import { ref } from 'vue'; |
|||
import { useRouter } from 'vue-router'; |
|||
|
|||
import { |
|||
AnalysisChartCard, |
|||
WorkbenchHeader, |
|||
WorkbenchProject, |
|||
WorkbenchQuickNav, |
|||
WorkbenchTodo, |
|||
WorkbenchTrends, |
|||
} from '@vben/common-ui'; |
|||
import { preferences } from '@vben/preferences'; |
|||
import { useUserStore } from '@vben/stores'; |
|||
import { openWindow } from '@vben/utils'; |
|||
|
|||
import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue'; |
|||
|
|||
const userStore = useUserStore(); |
|||
|
|||
// 这是一个示例数据,实际项目中需要根据实际情况进行调整 |
|||
// url 也可以是内部路由,在 navTo 方法中识别处理,进行内部跳转 |
|||
// 例如:url: /dashboard/workspace |
|||
const projectItems: WorkbenchProjectItem[] = [ |
|||
{ |
|||
color: '', |
|||
content: '不要等待机会,而要创造机会。', |
|||
date: '2021-04-01', |
|||
group: '开源组', |
|||
icon: 'carbon:logo-github', |
|||
title: 'Github', |
|||
url: 'https://github.com', |
|||
}, |
|||
{ |
|||
color: '#3fb27f', |
|||
content: '现在的你决定将来的你。', |
|||
date: '2021-04-01', |
|||
group: '算法组', |
|||
icon: 'ion:logo-vue', |
|||
title: 'Vue', |
|||
url: 'https://vuejs.org', |
|||
}, |
|||
{ |
|||
color: '#e18525', |
|||
content: '没有什么才能比努力更重要。', |
|||
date: '2021-04-01', |
|||
group: '上班摸鱼', |
|||
icon: 'ion:logo-html5', |
|||
title: 'Html5', |
|||
url: 'https://developer.mozilla.org/zh-CN/docs/Web/HTML', |
|||
}, |
|||
{ |
|||
color: '#bf0c2c', |
|||
content: '热情和欲望可以突破一切难关。', |
|||
date: '2021-04-01', |
|||
group: 'UI', |
|||
icon: 'ion:logo-angular', |
|||
title: 'Angular', |
|||
url: 'https://angular.io', |
|||
}, |
|||
{ |
|||
color: '#00d8ff', |
|||
content: '健康的身体是实现目标的基石。', |
|||
date: '2021-04-01', |
|||
group: '技术牛', |
|||
icon: 'bx:bxl-react', |
|||
title: 'React', |
|||
url: 'https://reactjs.org', |
|||
}, |
|||
{ |
|||
color: '#EBD94E', |
|||
content: '路是走出来的,而不是空想出来的。', |
|||
date: '2021-04-01', |
|||
group: '架构组', |
|||
icon: 'ion:logo-javascript', |
|||
title: 'Js', |
|||
url: 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript', |
|||
}, |
|||
]; |
|||
|
|||
// 同样,这里的 url 也可以使用以 http 开头的外部链接 |
|||
const quickNavItems: WorkbenchQuickNavItem[] = [ |
|||
{ |
|||
color: '#1fdaca', |
|||
icon: 'ion:home-outline', |
|||
title: '首页', |
|||
url: '/', |
|||
}, |
|||
{ |
|||
color: '#bf0c2c', |
|||
icon: 'ion:grid-outline', |
|||
title: '仪表盘', |
|||
url: '/dashboard', |
|||
}, |
|||
{ |
|||
color: '#e18525', |
|||
icon: 'ion:layers-outline', |
|||
title: '组件', |
|||
url: '/demos/features/icons', |
|||
}, |
|||
{ |
|||
color: '#3fb27f', |
|||
icon: 'ion:settings-outline', |
|||
title: '系统管理', |
|||
url: '/demos/features/login-expired', // 这里的 URL 是示例,实际项目中需要根据实际情况进行调整 |
|||
}, |
|||
{ |
|||
color: '#4daf1bc9', |
|||
icon: 'ion:key-outline', |
|||
title: '权限管理', |
|||
url: '/demos/access/page-control', |
|||
}, |
|||
{ |
|||
color: '#00d8ff', |
|||
icon: 'ion:bar-chart-outline', |
|||
title: '图表', |
|||
url: '/analytics', |
|||
}, |
|||
]; |
|||
|
|||
const todoItems = ref<WorkbenchTodoItem[]>([ |
|||
{ |
|||
completed: false, |
|||
content: `审查最近提交到Git仓库的前端代码,确保代码质量和规范。`, |
|||
date: '2024-07-30 11:00:00', |
|||
title: '审查前端代码提交', |
|||
}, |
|||
{ |
|||
completed: true, |
|||
content: `检查并优化系统性能,降低CPU使用率。`, |
|||
date: '2024-07-30 11:00:00', |
|||
title: '系统性能优化', |
|||
}, |
|||
{ |
|||
completed: false, |
|||
content: `进行系统安全检查,确保没有安全漏洞或未授权的访问。 `, |
|||
date: '2024-07-30 11:00:00', |
|||
title: '安全检查', |
|||
}, |
|||
{ |
|||
completed: false, |
|||
content: `更新项目中的所有npm依赖包,确保使用最新版本。`, |
|||
date: '2024-07-30 11:00:00', |
|||
title: '更新项目依赖', |
|||
}, |
|||
{ |
|||
completed: false, |
|||
content: `修复用户报告的页面UI显示问题,确保在不同浏览器中显示一致。 `, |
|||
date: '2024-07-30 11:00:00', |
|||
title: '修复UI显示问题', |
|||
}, |
|||
]); |
|||
const trendItems: WorkbenchTrendItem[] = [ |
|||
{ |
|||
avatar: 'svg:avatar-1', |
|||
content: `在 <a>开源组</a> 创建了项目 <a>Vue</a>`, |
|||
date: '刚刚', |
|||
title: '威廉', |
|||
}, |
|||
{ |
|||
avatar: 'svg:avatar-2', |
|||
content: `关注了 <a>威廉</a> `, |
|||
date: '1个小时前', |
|||
title: '艾文', |
|||
}, |
|||
{ |
|||
avatar: 'svg:avatar-3', |
|||
content: `发布了 <a>个人动态</a> `, |
|||
date: '1天前', |
|||
title: '克里斯', |
|||
}, |
|||
{ |
|||
avatar: 'svg:avatar-4', |
|||
content: `发表文章 <a>如何编写一个Vite插件</a> `, |
|||
date: '2天前', |
|||
title: 'Vben', |
|||
}, |
|||
{ |
|||
avatar: 'svg:avatar-1', |
|||
content: `回复了 <a>杰克</a> 的问题 <a>如何进行项目优化?</a>`, |
|||
date: '3天前', |
|||
title: '皮特', |
|||
}, |
|||
{ |
|||
avatar: 'svg:avatar-2', |
|||
content: `关闭了问题 <a>如何运行项目</a> `, |
|||
date: '1周前', |
|||
title: '杰克', |
|||
}, |
|||
{ |
|||
avatar: 'svg:avatar-3', |
|||
content: `发布了 <a>个人动态</a> `, |
|||
date: '1周前', |
|||
title: '威廉', |
|||
}, |
|||
{ |
|||
avatar: 'svg:avatar-4', |
|||
content: `推送了代码到 <a>Github</a>`, |
|||
date: '2021-04-01 20:00', |
|||
title: '威廉', |
|||
}, |
|||
{ |
|||
avatar: 'svg:avatar-4', |
|||
content: `发表文章 <a>如何编写使用 Admin Vben</a> `, |
|||
date: '2021-03-01 20:00', |
|||
title: 'Vben', |
|||
}, |
|||
]; |
|||
|
|||
const router = useRouter(); |
|||
|
|||
// 这是一个示例方法,实际项目中需要根据实际情况进行调整 |
|||
// This is a sample method, adjust according to the actual project requirements |
|||
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) { |
|||
if (nav.url?.startsWith('http')) { |
|||
openWindow(nav.url); |
|||
return; |
|||
} |
|||
if (nav.url?.startsWith('/')) { |
|||
router.push(nav.url).catch((error) => { |
|||
console.error('Navigation failed:', error); |
|||
}); |
|||
} else { |
|||
console.warn(`Unknown URL for navigation item: ${nav.title} -> ${nav.url}`); |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="p-5"> |
|||
<WorkbenchHeader |
|||
:avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar" |
|||
> |
|||
<template #title> |
|||
早安, {{ userStore.userInfo?.realName }}, 开始您一天的工作吧! |
|||
</template> |
|||
<template #description> 今日晴,20℃ - 32℃! </template> |
|||
</WorkbenchHeader> |
|||
|
|||
<div class="mt-5 flex flex-col lg:flex-row"> |
|||
<div class="mr-4 w-full lg:w-3/5"> |
|||
<WorkbenchProject :items="projectItems" title="项目" @click="navTo" /> |
|||
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" /> |
|||
</div> |
|||
<div class="w-full lg:w-2/5"> |
|||
<WorkbenchQuickNav |
|||
:items="quickNavItems" |
|||
class="mt-5 lg:mt-0" |
|||
title="快捷导航" |
|||
@click="navTo" |
|||
/> |
|||
<WorkbenchTodo :items="todoItems" class="mt-5" title="待办事项" /> |
|||
<AnalysisChartCard class="mt-5" title="访问来源"> |
|||
<AnalyticsVisitsSource /> |
|||
</AnalysisChartCard> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
@ -0,0 +1,66 @@ |
|||
<script lang="ts" setup> |
|||
import { Page } from '@vben/common-ui'; |
|||
|
|||
import { Button, Card, message, notification, Space } from 'ant-design-vue'; |
|||
|
|||
type NotificationType = 'error' | 'info' | 'success' | 'warning'; |
|||
|
|||
function info() { |
|||
message.info('How many roads must a man walk down'); |
|||
} |
|||
|
|||
function error() { |
|||
message.error({ |
|||
content: 'Once upon a time you dressed so fine', |
|||
duration: 2500, |
|||
}); |
|||
} |
|||
|
|||
function warning() { |
|||
message.warning('How many roads must a man walk down'); |
|||
} |
|||
function success() { |
|||
message.success('Cause you walked hand in hand With another man in my place'); |
|||
} |
|||
|
|||
function notify(type: NotificationType) { |
|||
notification[type]({ |
|||
duration: 2500, |
|||
message: '说点啥呢', |
|||
type, |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<Page |
|||
description="支持多语言,主题功能集成切换等" |
|||
title="Ant Design Vue组件使用演示" |
|||
> |
|||
<Card class="mb-5" title="按钮"> |
|||
<Space> |
|||
<Button>Default</Button> |
|||
<Button type="primary"> Primary </Button> |
|||
<Button> Info </Button> |
|||
<Button danger> Error </Button> |
|||
</Space> |
|||
</Card> |
|||
<Card class="mb-5" title="Message"> |
|||
<Space> |
|||
<Button @click="info"> 信息 </Button> |
|||
<Button danger @click="error"> 错误 </Button> |
|||
<Button @click="warning"> 警告 </Button> |
|||
<Button @click="success"> 成功 </Button> |
|||
</Space> |
|||
</Card> |
|||
|
|||
<Card class="mb-5" title="Notification"> |
|||
<Space> |
|||
<Button @click="notify('info')"> 信息 </Button> |
|||
<Button danger @click="notify('error')"> 错误 </Button> |
|||
<Button @click="notify('warning')"> 警告 </Button> |
|||
<Button @click="notify('success')"> 成功 </Button> |
|||
</Space> |
|||
</Card> |
|||
</Page> |
|||
</template> |
|||
@ -0,0 +1,241 @@ |
|||
/** |
|||
* 表格列配置 |
|||
*/ |
|||
|
|||
import type {VxeGridProps} from '#/adapter/vxe-table'; |
|||
|
|||
export const columns: VxeGridProps['columns'] = [ |
|||
|
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '主键ID', |
|||
// 对应字段
|
|||
field: 'id', |
|||
// 宽度
|
|||
width: 150, |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '账号', |
|||
|
|||
// 对应字段
|
|||
field: 'userName', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '手机号', |
|||
|
|||
// 对应字段
|
|||
field: 'userPhone', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '姓名', |
|||
|
|||
// 对应字段
|
|||
field: 'userRealName', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '性别:0默认保密,1男2女', |
|||
|
|||
// 对应字段
|
|||
field: 'userGender', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '密码', |
|||
|
|||
// 对应字段
|
|||
field: 'userPassword', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '生日', |
|||
|
|||
// 对应字段
|
|||
field: 'userBirthday', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '头像', |
|||
|
|||
// 对应字段
|
|||
field: 'userFace', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '邮箱', |
|||
|
|||
// 对应字段
|
|||
field: 'userEmail', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '创建时间', |
|||
|
|||
// 对应字段
|
|||
field: 'createdAt', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '更新时间', |
|||
|
|||
// 对应字段
|
|||
field: 'updatedAt', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '创建人', |
|||
|
|||
// 对应字段
|
|||
field: 'createdBy', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '更新人', |
|||
|
|||
// 对应字段
|
|||
field: 'updatedBy', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '删除 0 默认 1删除', |
|||
|
|||
// 对应字段
|
|||
field: 'deletedFlag', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '地址', |
|||
|
|||
// 对应字段
|
|||
field: 'userAddress', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '用户ID冗余字段', |
|||
|
|||
// 对应字段
|
|||
field: 'userId', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '系统型号', |
|||
|
|||
// 对应字段
|
|||
field: 'userSys', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '手机型号', |
|||
|
|||
// 对应字段
|
|||
field: 'userPhoneModel', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '用户状态默认启用0,注销1', |
|||
|
|||
// 对应字段
|
|||
field: 'userStatus', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '手机唯一标识', |
|||
|
|||
// 对应字段
|
|||
field: 'userCid', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
{ |
|||
// 列标题
|
|||
title: '手机IP', |
|||
|
|||
// 对应字段
|
|||
field: 'userIp', |
|||
|
|||
// 宽度
|
|||
width: 150 |
|||
}, |
|||
|
|||
|
|||
]; |
|||
@ -0,0 +1,258 @@ |
|||
/** |
|||
* 表单 schema |
|||
* component 类型来自 parse_component() |
|||
*/ |
|||
|
|||
import type {VbenFormSchema} from '#/adapter/form'; |
|||
|
|||
export const formSchema: VbenFormSchema[] = [ |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'id', |
|||
|
|||
// label
|
|||
label: '主键ID', |
|||
// 自动组件
|
|||
component: 'InputNumber' |
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userName', |
|||
// label
|
|||
label: '账号', |
|||
// 自动组件
|
|||
component: 'Input' |
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userPhone', |
|||
|
|||
// label
|
|||
label: '手机号', |
|||
|
|||
// 自动组件
|
|||
component: 'Input' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userRealName', |
|||
|
|||
// label
|
|||
label: '姓名', |
|||
|
|||
// 自动组件
|
|||
component: 'Input' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userGender', |
|||
|
|||
// label
|
|||
label: '性别:0默认保密,1男2女', |
|||
|
|||
// 自动组件
|
|||
component: 'InputNumber' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userPassword', |
|||
|
|||
// label
|
|||
label: '密码', |
|||
|
|||
// 自动组件
|
|||
component: 'Input' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userBirthday', |
|||
|
|||
// label
|
|||
label: '生日', |
|||
|
|||
// 自动组件
|
|||
component: 'DatePicker' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userFace', |
|||
|
|||
// label
|
|||
label: '头像', |
|||
|
|||
// 自动组件
|
|||
component: 'Input' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userEmail', |
|||
|
|||
// label
|
|||
label: '邮箱', |
|||
|
|||
// 自动组件
|
|||
component: 'Input' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'createdAt', |
|||
|
|||
// label
|
|||
label: '创建时间', |
|||
|
|||
// 自动组件
|
|||
component: 'DatePicker' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'updatedAt', |
|||
|
|||
// label
|
|||
label: '更新时间', |
|||
|
|||
// 自动组件
|
|||
component: 'DatePicker' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'createdBy', |
|||
|
|||
// label
|
|||
label: '创建人', |
|||
|
|||
// 自动组件
|
|||
component: 'InputNumber' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'updatedBy', |
|||
|
|||
// label
|
|||
label: '更新人', |
|||
|
|||
// 自动组件
|
|||
component: 'InputNumber' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'deletedFlag', |
|||
|
|||
// label
|
|||
label: '删除 0 默认 1删除', |
|||
|
|||
// 自动组件
|
|||
component: 'InputNumber' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userAddress', |
|||
|
|||
// label
|
|||
label: '地址', |
|||
|
|||
// 自动组件
|
|||
component: 'Input' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userId', |
|||
|
|||
// label
|
|||
label: '用户ID冗余字段', |
|||
|
|||
// 自动组件
|
|||
component: 'InputNumber' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userSys', |
|||
|
|||
// label
|
|||
label: '系统型号', |
|||
|
|||
// 自动组件
|
|||
component: 'Input' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userPhoneModel', |
|||
|
|||
// label
|
|||
label: '手机型号', |
|||
|
|||
// 自动组件
|
|||
component: 'Input' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userStatus', |
|||
|
|||
// label
|
|||
label: '用户状态默认启用0,注销1', |
|||
|
|||
// 自动组件
|
|||
component: 'InputNumber' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userCid', |
|||
|
|||
// label
|
|||
label: '手机唯一标识', |
|||
|
|||
// 自动组件
|
|||
component: 'Input' |
|||
|
|||
}, |
|||
|
|||
{ |
|||
// 字段名
|
|||
fieldName: 'userIp', |
|||
|
|||
// label
|
|||
label: '手机IP', |
|||
|
|||
// 自动组件
|
|||
component: 'Input' |
|||
|
|||
}, |
|||
|
|||
|
|||
]; |
|||
@ -0,0 +1,332 @@ |
|||
<script setup lang="ts"> |
|||
/** |
|||
* 用户管理页面 |
|||
*/ |
|||
import {ref, reactive, onMounted, watch} from 'vue'; |
|||
import {useVbenVxeGrid} from '#/adapter/vxe-table'; |
|||
import {useVbenModal} from '@vben/common-ui'; |
|||
import {useVbenForm} from '#/adapter/form'; |
|||
import dayjs from 'dayjs'; |
|||
import {columns} from './data'; |
|||
import {formSchema} from './form'; |
|||
import {userApi} from '#/api/user'; |
|||
|
|||
// ========== 状态变量 ========== |
|||
const currentRow = ref(null); |
|||
const isEdit = ref(false); |
|||
const modalTitle = ref('新增'); |
|||
// ========== 动态生成的查询表单 Schema ========== |
|||
const querySchema = ref([]); |
|||
// ========== 枚举数据配置 ========== |
|||
const enumData = reactive({ |
|||
loading: true, |
|||
list: [], // 存储接口返回的原始枚举数据 |
|||
}); |
|||
//todo 枚举数据在查询里面不展示的配置 |
|||
const hiddenColumns = ref(['createdAt', 'updatedBy', 'updatedAt', 'userPassword', 'userId', 'userSys', 'deletedFlag', "userFace"]) |
|||
|
|||
// ========== 初始化枚举数据和查询表单 ========== |
|||
async function initEnumData() { |
|||
try { |
|||
enumData.loading = true; |
|||
|
|||
// 调用枚举列表接口 |
|||
const res = await userApi.enumList({}); |
|||
const enums = res.result || res; |
|||
|
|||
// 保存原始枚举数据 |
|||
enumData.list = enums; |
|||
|
|||
// 根据 queryFields 和枚举数据动态生成查询表单 schema` |
|||
querySchema.value = formSchema.filter(formItem => { |
|||
// 2. 排除隐藏的字段 |
|||
if (hiddenColumns.value.includes(formItem.fieldName)) { |
|||
console.log('跳过隐藏字段:', formItem.fieldName) |
|||
return false; |
|||
} |
|||
return true; |
|||
}).map(formSchemaTmp => { |
|||
// 在枚举列表中查找匹配的字段 |
|||
const matchedEnums = enums.filter(item => item.fieldName === formSchemaTmp.fieldName); |
|||
|
|||
// 合并相同 fieldName 的所有 options |
|||
const allOptions = matchedEnums.reduce((acc, item) => { |
|||
if (item.options && Array.isArray(item.options)) { |
|||
return [...acc, ...item.options]; |
|||
} |
|||
return acc; |
|||
}, []); |
|||
// 判断是否有匹配的枚举选项 |
|||
if (allOptions.length > 0) { |
|||
return { |
|||
component: 'Select', |
|||
fieldName: formSchemaTmp.fieldName, |
|||
label: formSchemaTmp.label, |
|||
componentProps: { |
|||
placeholder: `请选择`, |
|||
allowClear: true, |
|||
options: allOptions.map(opt => ({ |
|||
label: opt.label, |
|||
value: String(opt.value), // 确保 value 是字符串 |
|||
})), |
|||
}, |
|||
}; |
|||
} else { |
|||
// ❌ 未匹配到枚举 → 使用 Input 组件 |
|||
return { |
|||
component: 'Input', |
|||
fieldName: formSchemaTmp.fieldName, |
|||
label: formSchemaTmp.label, |
|||
componentProps: { |
|||
placeholder: `请输入${formSchemaTmp.label}`, |
|||
allowClear: true, |
|||
}, |
|||
}; |
|||
} |
|||
}); |
|||
} catch (error) { |
|||
} finally { |
|||
enumData.loading = false; |
|||
} |
|||
} |
|||
|
|||
// 页面加载时自动执行初始化 |
|||
onMounted(() => { |
|||
initEnumData(); |
|||
}); |
|||
|
|||
|
|||
// ========== 查询表单配置 (初始为空 schema) ========== |
|||
const [QueryForm, queryFormApi] = useVbenForm({ |
|||
schema: [], // 初始化为空数组 |
|||
layout: 'inline', |
|||
showDefaultActions: false, |
|||
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-5', |
|||
}) |
|||
// ========== 监听 querySchema 变化并更新表单 ========== |
|||
watch( |
|||
querySchema, |
|||
(newSchema) => { |
|||
if (newSchema && newSchema.length > 0) { |
|||
console.log('🔄 更新查询表单 schema:', newSchema); |
|||
// ✅ 使用 setState 方法更新 schema |
|||
queryFormApi.setState({ |
|||
schema: newSchema, |
|||
}); |
|||
} |
|||
}, |
|||
{immediate: false} |
|||
); |
|||
|
|||
// ========== 表格配置 ========== |
|||
const gridOptions = { |
|||
columns: [ |
|||
...columns, |
|||
{ |
|||
field: 'action', |
|||
title: '操作', |
|||
width: 120, |
|||
fixed: 'right', |
|||
slots: {default: 'action'}, |
|||
}, |
|||
], |
|||
proxyConfig: { |
|||
ajax: { |
|||
query: async ({page}) => { |
|||
try { |
|||
// 获取查询表单的值 |
|||
const queryValues = await queryFormApi.getValues(); |
|||
|
|||
console.log('=== 查询参数 ===', { |
|||
pageNum: page?.currentPage || 1, |
|||
pageSize: page?.pageSize || 10, |
|||
...queryValues, |
|||
}) |
|||
|
|||
const res = await userApi.page({ |
|||
pageNum: page?.currentPage || 1, |
|||
pageSize: page?.pageSize || 10, |
|||
...queryValues, // 携带查询条件 |
|||
}) |
|||
|
|||
const data = res.error?.result || res.result || res |
|||
|
|||
return { |
|||
items: data.records || [], |
|||
total: data.total || 0 |
|||
} |
|||
|
|||
} catch (error) { |
|||
console.error('查询用户列表失败:', error) |
|||
throw error |
|||
} |
|||
} |
|||
}, |
|||
response: { |
|||
result: 'items', |
|||
total: 'total' |
|||
} |
|||
}, |
|||
pagerConfig: { |
|||
enabled: true, |
|||
pageSize: 10, |
|||
}, |
|||
} |
|||
|
|||
const [Grid, gridApi] = useVbenVxeGrid({gridOptions}) |
|||
|
|||
// ========== 弹窗配置 ========== |
|||
const [Modal, modalApi] = useVbenModal({ |
|||
centered: true, |
|||
closable: true, |
|||
maskClosable: false, |
|||
draggable: true, |
|||
width: 800, |
|||
onCancel() { |
|||
modalApi.close(); |
|||
}, |
|||
onConfirm: async () => { |
|||
try { |
|||
const values = await formApi.validateAndSubmitForm(); |
|||
if (!values) return; |
|||
|
|||
// 处理日期格式,将 Day.js 对象转换为字符串 |
|||
const submitValues = { |
|||
...values, |
|||
userBirthday: values.userBirthday ? dayjs(values.userBirthday).format('YYYY-MM-DD') : null, |
|||
}; |
|||
|
|||
if (isEdit.value && currentRow.value?.id) { |
|||
await userApi.save({...submitValues, id: currentRow.value.id}); |
|||
} else { |
|||
await userApi.save(submitValues); |
|||
} |
|||
|
|||
modalApi.close(); |
|||
gridApi.reload(); |
|||
} catch (error) { |
|||
console.error('保存失败:', error); |
|||
} |
|||
}, |
|||
onOpenChange(isOpen: boolean) { |
|||
if (!isOpen && !isEdit.value) { |
|||
formApi.resetForm(); |
|||
} |
|||
}, |
|||
}) |
|||
|
|||
// ========== 表单配置 ========== |
|||
const [Form, formApi] = useVbenForm({ |
|||
schema: formSchema, |
|||
showDefaultActions: false, |
|||
wrapperClass: 'grid-cols-1 md:grid-cols-2', |
|||
commonConfig: { |
|||
componentProps: { |
|||
class: 'w-full', |
|||
autocomplete: 'off', |
|||
}, |
|||
}, |
|||
}) |
|||
|
|||
// ========== 打开新增弹窗 ========== |
|||
function handleAdd() { |
|||
isEdit.value = false; |
|||
modalTitle.value = '新增用户'; |
|||
formApi.resetForm(); |
|||
modalApi.open(); |
|||
} |
|||
|
|||
// ========== 打开编辑弹窗 ========== |
|||
function handleEdit(row: any) { |
|||
isEdit.value = true; |
|||
currentRow.value = row; |
|||
modalTitle.value = '编辑用户'; |
|||
|
|||
// 设置表单值,过滤掉不必要的字段,并处理日期格式 |
|||
const formValues = { |
|||
id: row.id, |
|||
userName: row.userName, |
|||
userPhone: row.userPhone, |
|||
userRealName: row.userRealName, |
|||
userGender: row.userGender, |
|||
userPassword: row.userPassword, |
|||
userBirthday: row.userBirthday, |
|||
userFace: row.userFace, |
|||
userEmail: row.userEmail, |
|||
userAddress: row.userAddress, |
|||
userId: row.userId, |
|||
userSys: row.userSys, |
|||
userPhoneModel: row.userPhoneModel, |
|||
userStatus: row.userStatus, |
|||
userCid: row.userCid, |
|||
userIp: row.userIp, |
|||
}; |
|||
|
|||
formApi.setValues(formValues); |
|||
modalApi.open(); |
|||
} |
|||
|
|||
// ========== 删除确认 ========== |
|||
function handleDelete(row: any) { |
|||
if (!row.id) return; |
|||
window.confirm(`确定要删除 "${row.userName}" 吗?`) && |
|||
userApi.remove(row.id).then(() => { |
|||
gridApi.reload(); |
|||
}); |
|||
} |
|||
|
|||
// ========== 查询功能 ========== |
|||
function handleSearch() { |
|||
console.log('=== 点击查询按钮 ===') |
|||
// 先重置到第一页,然后执行查询 |
|||
gridApi.query(); |
|||
} |
|||
|
|||
// ========== 重置查询 ========== |
|||
function handleReset() { |
|||
queryFormApi.resetForm(); |
|||
gridApi.reload(); |
|||
} |
|||
|
|||
</script> |
|||
|
|||
<template> |
|||
<div style="height: 100vh; padding: 16px; box-sizing: border-box;"> |
|||
<!-- 查询表单 --> |
|||
<div class="bg-card mb-4 p-4 rounded shadow"> |
|||
<h3 class="text-lg font-semibold mb-3">查询条件</h3> |
|||
<QueryForm/> |
|||
<div class="mt-3 flex gap-2"> |
|||
<button @click="handleSearch" |
|||
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"> |
|||
🔍 查询 |
|||
</button> |
|||
<button @click="handleReset" |
|||
class="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600"> |
|||
🔄 重置 |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 数据表格 --> |
|||
<Grid> |
|||
<template #toolbar-tools> |
|||
<button @click="handleAdd" class="mr-2">➕ 新增</button> |
|||
<button @click="() => gridApi.reload()">🔄 刷新</button> |
|||
</template> |
|||
|
|||
<template #action="{ row }"> |
|||
<div class="flex gap-2"> |
|||
<button @click="() => handleEdit(row)" class="text-blue-500 hover:text-blue-700">✏️ 编辑 |
|||
</button> |
|||
<button @click="() => handleDelete(row)" class="text-red-500 hover:text-red-700">🗑️ 删除 |
|||
</button> |
|||
</div> |
|||
</template> |
|||
</Grid> |
|||
|
|||
<Modal :title="modalTitle"> |
|||
<Form/> |
|||
</Modal> |
|||
</div> |
|||
</template> |
|||
@ -0,0 +1 @@ |
|||
export { default } from '@vben/tailwind-config'; |
|||
@ -0,0 +1,12 @@ |
|||
{ |
|||
"$schema": "https://json.schemastore.org/tsconfig", |
|||
"extends": "@vben/tsconfig/web-app.json", |
|||
"compilerOptions": { |
|||
"baseUrl": ".", |
|||
"paths": { |
|||
"#/*": ["./src/*"] |
|||
} |
|||
}, |
|||
"references": [{ "path": "./tsconfig.node.json" }], |
|||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
{ |
|||
"$schema": "https://json.schemastore.org/tsconfig", |
|||
"extends": "@vben/tsconfig/node.json", |
|||
"compilerOptions": { |
|||
"composite": true, |
|||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", |
|||
"noEmit": false |
|||
}, |
|||
"include": ["vite.config.mts"] |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
import { defineConfig } from '@vben/vite-config'; |
|||
|
|||
export default defineConfig(async () => { |
|||
return { |
|||
application: {}, |
|||
vite: { |
|||
server: { |
|||
proxy: { |
|||
'/api': { |
|||
changeOrigin: true, |
|||
rewrite: (path) => path.replace(/^\/api/, ''), |
|||
// mock代理目标地址 |
|||
mockPath: "apps/web-antd/mock", |
|||
target: 'http://localhost:5320/api', |
|||
ws: true, |
|||
}, |
|||
}, |
|||
}, |
|||
} |
|||
}; |
|||
}); |
|||
Loading…
Reference in new issue