@ -0,0 +1,14 @@ |
|||
# 供docker构建时使用 |
|||
ENV = 'production' |
|||
|
|||
# 网站前缀 |
|||
VITE_BASE_URL = / |
|||
|
|||
# base api |
|||
VITE_BASE_API = '/api/admin/' |
|||
VITE_BASE_SOCKET_PATH = '/ws-api' |
|||
VITE_BASE_SOCKET_NSP = '/admin' |
|||
|
|||
# mock api |
|||
VITE_MOCK_API = '/mock-api/' |
|||
|
|||
@ -0,0 +1,14 @@ |
|||
# 只在开发模式中被载入 |
|||
|
|||
# 网站前缀 |
|||
VITE_BASE_API_URL = http://192.168.3.118:10010/admin-api/ |
|||
|
|||
# base api |
|||
VITE_BASE_API = '/admin-api/' |
|||
VITE_BASE_SOCKET_PATH = '/ws-api' |
|||
VITE_BASE_SOCKET_NSP = '/admin' |
|||
|
|||
# mock api |
|||
VITE_MOCK_API = '/mock-api/' |
|||
|
|||
VITE_DROP_CONSOLE = false |
|||
@ -0,0 +1,14 @@ |
|||
# 只在生产模式中被载入 |
|||
|
|||
# 网站前缀 |
|||
VITE_BASE_API_URL = http://192.168.3.118:10010/admin-api/ |
|||
|
|||
# base api |
|||
VITE_BASE_API = '/admin-api/' |
|||
VITE_BASE_SOCKET_PATH = '/ws-api' |
|||
VITE_BASE_SOCKET_NSP = '/admin' |
|||
|
|||
# mock api |
|||
VITE_MOCK_API = '/mock-api/' |
|||
|
|||
VITE_DROP_CONSOLE = true |
|||
@ -0,0 +1,24 @@ |
|||
# Logs |
|||
logs |
|||
*.log |
|||
npm-debug.log* |
|||
yarn-debug.log* |
|||
yarn-error.log* |
|||
pnpm-debug.log* |
|||
lerna-debug.log* |
|||
|
|||
node_modules |
|||
dist |
|||
dist-ssr |
|||
*.local |
|||
|
|||
# Editor directories and files |
|||
.vscode/* |
|||
!.vscode/extensions.json |
|||
.idea |
|||
.DS_Store |
|||
*.suo |
|||
*.ntvs* |
|||
*.njsproj |
|||
*.sln |
|||
*.sw? |
|||
@ -0,0 +1,100 @@ |
|||
<!-- |
|||
* @Author: AaronWu 2463371514@qq.com |
|||
* @Date: 2025-03-31 15:12:18 |
|||
* @LastEditors: AaronWu 2463371514@qq.com |
|||
* @LastEditTime: 2025-03-31 15:12:42 |
|||
* @FilePath: /IssueSupportManage/README.md |
|||
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE |
|||
--> |
|||
# IssueSupportManage |
|||
|
|||
template |
|||
|
|||
## Getting started |
|||
|
|||
To make it easy for you to get started with GitLab, here's a list of recommended next steps. |
|||
|
|||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! |
|||
|
|||
## Add your files |
|||
|
|||
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files |
|||
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: |
|||
|
|||
``` |
|||
cd existing_repo |
|||
git remote add origin https://git.koalacloud.com/koalacloud/web/kaola-cloud-platform/koalacloud-platform-web.git |
|||
git branch -M main |
|||
git push -uf origin main |
|||
``` |
|||
|
|||
## Integrate with your tools |
|||
|
|||
- [ ] [Set up project integrations](https://git.koalacloud.com/koalacloud/web/kaola-cloud-platform/koalacloud-platform-web/-/settings/integrations) |
|||
|
|||
## Collaborate with your team |
|||
|
|||
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) |
|||
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) |
|||
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) |
|||
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) |
|||
- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) |
|||
|
|||
## Test and Deploy |
|||
|
|||
Use the built-in continuous integration in GitLab. |
|||
|
|||
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) |
|||
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) |
|||
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) |
|||
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) |
|||
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) |
|||
|
|||
*** |
|||
|
|||
# Editing this README |
|||
|
|||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template. |
|||
|
|||
## Suggestions for a good README |
|||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. |
|||
|
|||
## Name |
|||
Choose a self-explaining name for your project. |
|||
|
|||
## Description |
|||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. |
|||
|
|||
## Badges |
|||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. |
|||
|
|||
## Visuals |
|||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. |
|||
|
|||
## Installation |
|||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. |
|||
|
|||
## Usage |
|||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. |
|||
|
|||
## Support |
|||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. |
|||
|
|||
## Roadmap |
|||
If you have ideas for releases in the future, it is a good idea to list them in the README. |
|||
|
|||
## Contributing |
|||
State if you are open to contributions and what your requirements are for accepting them. |
|||
|
|||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. |
|||
|
|||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. |
|||
|
|||
## Authors and acknowledgment |
|||
Show your appreciation to those who have contributed to the project. |
|||
|
|||
## License |
|||
For open source projects, say how it is licensed. |
|||
|
|||
## Project status |
|||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. |
|||
@ -0,0 +1,55 @@ |
|||
// generated by unplugin-vue-components
|
|||
// We suggest you to commit this file into source control
|
|||
// Read more: https://github.com/vuejs/core/pull/3399
|
|||
import '@vue/runtime-core' |
|||
|
|||
export {} |
|||
|
|||
declare module '@vue/runtime-core' { |
|||
export interface GlobalComponents { |
|||
AButton: typeof import('ant-design-vue/es')['Button'] |
|||
ACard: typeof import('ant-design-vue/es')['Card'] |
|||
ACol: typeof import('ant-design-vue/es')['Col'] |
|||
AForm: typeof import('ant-design-vue/es')['Form'] |
|||
AFormItem: typeof import('ant-design-vue/es')['FormItem'] |
|||
AInput: typeof import('ant-design-vue/es')['Input'] |
|||
ALayout: typeof import('ant-design-vue/es')['Layout'] |
|||
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent'] |
|||
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider'] |
|||
ApiSelect: typeof import('./src/components/core/schema-form/src/components/ApiSelect.vue')['default'] |
|||
ARow: typeof import('ant-design-vue/es')['Row'] |
|||
ASelect: typeof import('ant-design-vue/es')['Select'] |
|||
ASpin: typeof import('ant-design-vue/es')['Spin'] |
|||
ATypographyText: typeof import('ant-design-vue/es')['TypographyText'] |
|||
BasicArrow: typeof import('./src/components/basic/basic-arrow/index.vue')['default'] |
|||
BasicHelp: typeof import('./src/components/basic/basic-help/index.vue')['default'] |
|||
BasicTree: typeof import('./src/components/core/Tree/src/BasicTree.vue')['default'] |
|||
Button: typeof import('./src/components/basic/button/button.vue')['default'] |
|||
CheckBox: typeof import('./src/components/basic/check-box/index.vue')['default'] |
|||
CollapseContainer: typeof import('./src/components/core/Container/src/collapse/CollapseContainer.vue')['default'] |
|||
CollapseHeader: typeof import('./src/components/core/Container/src/collapse/CollapseHeader.vue')['default'] |
|||
CollapseTransition: typeof import('./src/components/core/Transition/src/CollapseTransition.vue')['default'] |
|||
ColumnSetting: typeof import('./src/components/core/dynamic-table/src/components/table-settings/column-setting.vue')['default'] |
|||
ContextMenu: typeof import('./src/components/basic/ContextMenu/src/ContextMenu.vue')['default'] |
|||
DraggableModal: typeof import('./src/components/core/draggable-modal/index.vue')['default'] |
|||
DynamicTable: typeof import('./src/components/core/dynamic-table/src/dynamic-table.vue')['default'] |
|||
EditableCell: typeof import('./src/components/core/dynamic-table/src/components/editable-cell/index.vue')['default'] |
|||
FormAction: typeof import('./src/components/core/schema-form/src/components/form-action.vue')['default'] |
|||
IconsSelect: typeof import('./src/components/basic/icons-select/index.vue')['default'] |
|||
IframePage: typeof import('./src/components/iframe-page/index.vue')['default'] |
|||
LocalePicker: typeof import('./src/components/basic/locale-picker/index.vue')['default'] |
|||
RefreshSetting: typeof import('./src/components/core/dynamic-table/src/components/table-settings/refresh-setting.vue')['default'] |
|||
RouterLink: typeof import('vue-router')['RouterLink'] |
|||
RouterView: typeof import('vue-router')['RouterView'] |
|||
SchemaForm: typeof import('./src/components/core/schema-form/src/schema-form.vue')['default'] |
|||
SchemaFormItem: typeof import('./src/components/core/schema-form/src/schema-form-item.vue')['default'] |
|||
SizeSetting: typeof import('./src/components/core/dynamic-table/src/components/table-settings/size-setting.vue')['default'] |
|||
SplitPanel: typeof import('./src/components/basic/split-panel/index.vue')['default'] |
|||
SvgIcon: typeof import('./src/components/basic/svg-icon/svg-icon.vue')['default'] |
|||
TableAction: typeof import('./src/components/basic/tableAction/index.vue')['default'] |
|||
TableSettings: typeof import('./src/components/core/dynamic-table/src/components/table-settings/index.vue')['default'] |
|||
TitleI18n: typeof import('./src/components/basic/title-i18n/index.vue')['default'] |
|||
ToolBar: typeof import('./src/components/core/dynamic-table/src/components/tool-bar/index.vue')['default'] |
|||
TreeHeader: typeof import('./src/components/core/Tree/src/components/TreeHeader.vue')['default'] |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="UTF-8" /> |
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|||
<title>Vite + Vue + TS</title> |
|||
</head> |
|||
<body> |
|||
<div id="app"></div> |
|||
<script type="module" src="/src/main.ts"></script> |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,10 @@ |
|||
// middlewares/cache.ts
|
|||
import { Request, Response, NextFunction } from 'express' |
|||
|
|||
export default function cacheMiddleware(req: Request, res: Response, next: NextFunction) { |
|||
if (process.env.NODE_ENV === 'production') { // 只在生产环境中启用缓存
|
|||
const cacheTime = 60 * 60 * 24 // 设置缓存时间,这里为一天
|
|||
res.setHeader('Cache-Control', `public, max-age=${cacheTime}`) |
|||
} |
|||
next() |
|||
} |
|||
@ -0,0 +1,48 @@ |
|||
{ |
|||
"name": "my-vue-app", |
|||
"private": true, |
|||
"version": "0.0.0", |
|||
"type": "module", |
|||
"scripts": { |
|||
"serve": "vite", |
|||
"build": "rimraf dist && cross-env NODE_ENV=production vite build", |
|||
"preview": "vite preview", |
|||
"clean:cache": "npx rimraf node_modules/.cache/ && npx rimraf node_modules/.vite", |
|||
"clean:lib": "npx rimraf node_modules", |
|||
"format": "prettier --write ." |
|||
}, |
|||
"dependencies": { |
|||
"@vitejs/plugin-vue-jsx": "^3.0.1", |
|||
"@vueuse/core": "~9.6.0", |
|||
"ant-design-vue": "^3.2.20", |
|||
"axios": "^1.4.0", |
|||
"express": "^4.18.2", |
|||
"file-saver": "^2.0.5", |
|||
"pinia": "^2.1.3", |
|||
"qs": "^6.11.2", |
|||
"sortablejs": "^1.15.0", |
|||
"vite-plugin-windicss": "^1.9.0", |
|||
"vue": "^3.2.47", |
|||
"vue-i18n": "9.2.2", |
|||
"vue-router": "^4.2.1", |
|||
"windicss": "^3.5.6", |
|||
"xlsx": "^0.18.5" |
|||
}, |
|||
"devDependencies": { |
|||
"@types/node": "^20.2.3", |
|||
"@vitejs/plugin-vue": "^4.1.0", |
|||
"less": "^4.1.3", |
|||
"less-loader": "^11.1.0", |
|||
"lodash-es": "~4.17.21", |
|||
"nprogress": "~1.0.0-1", |
|||
"rimraf": "^5.0.1", |
|||
"rollup-plugin-terser": "^7.0.2", |
|||
"typescript": "^5.0.2", |
|||
"unplugin-vue-components": "~0.22.12", |
|||
"unplugin-vue-define-options": "~1.0.0", |
|||
"vite": "^4.3.2", |
|||
"vite-plugin-svg-icons": "~2.0.1", |
|||
"vite-tsconfig-paths": "^4.2.0", |
|||
"vue-tsc": "^1.4.2" |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
module.exports = { |
|||
printWidth: 100, |
|||
semi: true, |
|||
vueIndentScriptAndStyle: true, |
|||
singleQuote: true, |
|||
trailingComma: 'all', |
|||
proseWrap: 'never', |
|||
htmlWhitespaceSensitivity: 'strict', |
|||
endOfLine: 'auto', |
|||
}; |
|||
@ -0,0 +1,38 @@ |
|||
<template> |
|||
<ConfigProvider :locale="getAntdLocale"> |
|||
<router-view #="{ Component }"> |
|||
<component :is="Component" /> |
|||
</router-view> |
|||
</ConfigProvider> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { watchEffect } from 'vue'; |
|||
import { useRoute } from 'vue-router'; |
|||
import { ConfigProvider } from 'ant-design-vue'; |
|||
import { transformI18n } from './hooks/useI18n'; |
|||
import { useLocale } from '@/locales/useLocale'; |
|||
|
|||
const route = useRoute(); |
|||
const { getAntdLocale } = useLocale(); |
|||
|
|||
watchEffect(() => { |
|||
|
|||
if (route.meta?.title) { |
|||
// 翻译网页标题 |
|||
document.title = transformI18n(route.meta.title); |
|||
} |
|||
}); |
|||
</script> |
|||
|
|||
<style lang="less"> |
|||
.ant-message-custom-content { |
|||
display: flex; |
|||
align-items: center; |
|||
} |
|||
|
|||
.anticon svg{ |
|||
display: block !important; |
|||
} |
|||
|
|||
</style> |
|||
@ -0,0 +1,39 @@ |
|||
import type { BaseResponse } from '@/utils/request'; |
|||
import { request } from '@/utils/request'; |
|||
|
|||
export function updateAccountInfo(data: any) { |
|||
return request<BaseResponse<any>>({ |
|||
url: 'account/update', |
|||
method: 'post', |
|||
data, |
|||
}); |
|||
} |
|||
|
|||
export function updatePassword(data: any) { |
|||
return request({ |
|||
url: 'account/password', |
|||
method: 'post', |
|||
data, |
|||
}); |
|||
} |
|||
|
|||
export function getInfo() { |
|||
return request<API.AdminUserInfo>({ |
|||
url: 'account/info', |
|||
method: 'get', |
|||
}); |
|||
} |
|||
|
|||
export function permmenu() { |
|||
return request<API.PermMenu>({ |
|||
url: 'account/permmenu', |
|||
method: 'get', |
|||
}); |
|||
} |
|||
|
|||
export function logout() { |
|||
return request({ |
|||
url: 'account/logout', |
|||
method: 'post', |
|||
}); |
|||
} |
|||
@ -0,0 +1,50 @@ |
|||
declare namespace API { |
|||
type Menu = { |
|||
createTime: Date; |
|||
updateTime: Date; |
|||
id: number; |
|||
parentId: number; |
|||
name: string; |
|||
router: string; |
|||
perms: string; |
|||
/** 当前菜单类型 0: 目录 | 1: 菜单 | 2: 权限 */ |
|||
type: 0 | 1 | 2; |
|||
icon: string; |
|||
orderNum: number; |
|||
viewPath: string; |
|||
keepalive: boolean; |
|||
isShow: boolean; |
|||
/** 是否外链 */ |
|||
isExt?: boolean; |
|||
/** 外链打开方式 |
|||
* 1: 新窗口打开 |
|||
* 2: iframe |
|||
*/ |
|||
openMode?: 1 | 2; |
|||
}; |
|||
|
|||
type PermMenu = { |
|||
menus: Menu[]; |
|||
perms: string[]; |
|||
}; |
|||
|
|||
type AdminUserInfo = { |
|||
createTime: Date; |
|||
updateTime: Date; |
|||
id: number; |
|||
departmentId: number; |
|||
name: string; |
|||
username: string; |
|||
password: string; |
|||
psalt: string; |
|||
nickName: string; |
|||
headImg: string; |
|||
loginIp: string; |
|||
email: string; |
|||
phone: string; |
|||
remark: string; |
|||
status: number; |
|||
roles: number[]; |
|||
departmentName: string; |
|||
}; |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
import type { BaseResponse } from '@/utils/request'; |
|||
import { request } from '@/utils/request'; |
|||
|
|||
|
|||
/** |
|||
* @description 查询分页列表 |
|||
* @param {SearchPageListParams} data |
|||
* @returns |
|||
*/ |
|||
export function fetchAuthorizePageList(data: API.SearchPageListParams) { |
|||
return request<BaseResponse<API.SearchPageListResult>>( |
|||
{ |
|||
url: 'grant/getTenantAppPage', |
|||
method: 'post', |
|||
data, |
|||
}, |
|||
{ |
|||
isGetDataDirectly: false, |
|||
}, |
|||
); |
|||
} |
|||
|
|||
|
|||
@ -0,0 +1,20 @@ |
|||
import { AnyRecord } from "dns"; |
|||
|
|||
declare namespace API { |
|||
|
|||
type infoType = { |
|||
id:string |
|||
name:string |
|||
} |
|||
|
|||
/** 授权信息类型 */ |
|||
type AuthorizeInfoType = { |
|||
id?: string; |
|||
tenant:infoType; |
|||
application:infoType; |
|||
expirationTime:string; |
|||
echoMap:AnyRecord |
|||
}; |
|||
|
|||
|
|||
} |
|||
@ -0,0 +1,109 @@ |
|||
import type { BaseResponse } from '@/utils/request'; |
|||
import { request } from '@/utils/request'; |
|||
/** |
|||
* @description 查询列表 |
|||
* @param {SearchListParams} data |
|||
* @returns |
|||
*/ |
|||
export function fetchDictionaryList(data: API.SearchListParams) { |
|||
return request<BaseResponse<API.SearchListResult>>( |
|||
{ |
|||
url: 'system/dict/query', |
|||
method: 'post', |
|||
data, |
|||
}, |
|||
{ |
|||
isGetDataDirectly: false, |
|||
}, |
|||
); |
|||
} |
|||
|
|||
/** |
|||
* @description 查询分页列表 |
|||
* @param {SearchPageListParams} data |
|||
* @returns |
|||
*/ |
|||
export function fetchDictionaryPageList(data: API.SearchPageListParams) { |
|||
return request<BaseResponse<API.SearchPageListResult>>( |
|||
{ |
|||
url: 'system/dict/page', |
|||
method: 'post', |
|||
data, |
|||
}, |
|||
{ |
|||
isGetDataDirectly: false, |
|||
}, |
|||
); |
|||
} |
|||
|
|||
|
|||
/** |
|||
* @description 新增单条 |
|||
* @param {DictionaryInfoType} data |
|||
* @returns |
|||
*/ |
|||
export function createDictionary(data: API.DictionaryInfoType) { |
|||
return request<BaseResponse<API.SearchListResult>>( |
|||
{ |
|||
url: 'system/dict/create', |
|||
method: 'post', |
|||
data, |
|||
}, |
|||
{ |
|||
isGetDataDirectly: false, |
|||
}, |
|||
); |
|||
} |
|||
|
|||
/** |
|||
* @description 修改单条 |
|||
* @param {DictionaryInfoType} data |
|||
* @returns |
|||
*/ |
|||
export function updateDictionary(data: API.DictionaryInfoType) { |
|||
return request<BaseResponse<API.SearchListResult>>( |
|||
{ |
|||
url: 'system/dict/update', |
|||
method: 'post', |
|||
data, |
|||
}, |
|||
{ |
|||
isGetDataDirectly: false, |
|||
}, |
|||
); |
|||
} |
|||
|
|||
/** |
|||
* @description 查询单条 |
|||
*/ |
|||
export function findOneById(params: { |
|||
id:string |
|||
}) { |
|||
return request<API.SearchListResult>({ |
|||
url: `system/dict/getById`, |
|||
method: 'get', |
|||
params |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* @description 删除单条 |
|||
*/ |
|||
export function deleteDictionaryById(params:API.DeleteDictionaryParams) { |
|||
return request<API.SearchListResult>({ |
|||
url: `system/dict/delById`, |
|||
method: 'post', |
|||
params |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* @description 删除多条 |
|||
*/ |
|||
export function deleteBatchDictionaryById(data:API.DeleteBatchDictionaryParams) { |
|||
return request<API.SearchListResult>({ |
|||
url: `system/dict/deleteBatch`, |
|||
method: 'delete', |
|||
data, |
|||
}); |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
declare namespace API { |
|||
/** 用户信息类型 */ |
|||
type DictionaryInfoType = { |
|||
id?: string; |
|||
dictKey: string; |
|||
name: string; |
|||
status: boolean; |
|||
createTime?: string; |
|||
}; |
|||
|
|||
type DictionaryItemInfoType = DictionaryInfoType & { |
|||
sortValue: number | string; |
|||
}; |
|||
|
|||
type CreateDictionaryParams = { |
|||
dictKey: string; |
|||
name: string; |
|||
status: boolean; |
|||
createTime: string; |
|||
}; |
|||
|
|||
type DeleteDictionaryParams = { |
|||
id: string; |
|||
}; |
|||
|
|||
type DeleteBatchDictionaryParams = string[]; |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
import type { BaseResponse } from '@/utils/request'; |
|||
import { request } from '@/utils/request'; |
|||
|
|||
/** |
|||
* @description 登录 |
|||
* @param {LoginParams} data |
|||
* @returns |
|||
*/ |
|||
export function login(data: API.LoginParams) { |
|||
return request<BaseResponse<API.LoginResult>>( |
|||
{ |
|||
url: 'auth/login', |
|||
method: 'post', |
|||
data, |
|||
}, |
|||
{ |
|||
isGetDataDirectly: false, |
|||
}, |
|||
); |
|||
} |
|||
/** |
|||
* @description 获取验证码 |
|||
*/ |
|||
export function getImageCaptcha(params?: API.CaptchaParams) { |
|||
return request<API.CaptchaResult>({ |
|||
url: 'captcha/img', |
|||
method: 'get', |
|||
params, |
|||
}); |
|||
} |
|||
@ -0,0 +1,54 @@ |
|||
declare namespace API { |
|||
|
|||
type Menu = { |
|||
createTime: Date; |
|||
updateTime: Date; |
|||
id: number; |
|||
parentId: number; |
|||
name: string; |
|||
router: string; |
|||
perms: string; |
|||
/** 当前菜单类型 0: 目录 | 1: 菜单 | 2: 权限 */ |
|||
type: 0 | 1 | 2; |
|||
icon: string; |
|||
orderNum: number; |
|||
viewPath: string; |
|||
keepalive: boolean; |
|||
isShow: boolean; |
|||
/** 是否外链 */ |
|||
isExt?: boolean; |
|||
/** 外链打开方式 |
|||
* 1: 新窗口打开 |
|||
* 2: iframe |
|||
*/ |
|||
openMode?: 1 | 2; |
|||
}; |
|||
|
|||
/** 登录参数 */ |
|||
type LoginParams = { |
|||
captchaId?: string; |
|||
password: string; |
|||
username: string; |
|||
verifyCode?: string; |
|||
}; |
|||
|
|||
/** 登录成功结果 */ |
|||
type LoginResult = { |
|||
accessToken: string; |
|||
expiresTime:string; |
|||
refreshToken:string; |
|||
userId:string; |
|||
}; |
|||
|
|||
/** 获取验证码参数 */ |
|||
type CaptchaParams = { |
|||
width?: number; |
|||
height?: number; |
|||
}; |
|||
|
|||
/** 获取验证码结果 */ |
|||
type CaptchaResult = { |
|||
img: string; |
|||
id: string; |
|||
}; |
|||
} |
|||
@ -0,0 +1,65 @@ |
|||
// @ts-ignore
|
|||
/* eslint-disable */ |
|||
|
|||
declare namespace API { |
|||
|
|||
|
|||
/** 查询列表参数 */ |
|||
type SearchListParams = { |
|||
name?: string; |
|||
}; |
|||
|
|||
/** 查询分页列表参数 */ |
|||
type SearchPageListParams = { |
|||
model: SearchListParams; |
|||
current: number; |
|||
size: number; |
|||
sort?: string; |
|||
order?: string; |
|||
}; |
|||
|
|||
/** 查询列表成功结果 */ |
|||
type SearchListResult = any[] |
|||
|
|||
/** 查询分页列表成功结果 */ |
|||
type SearchPageListResult = { |
|||
// code: string;
|
|||
// data: {
|
|||
// list:TenantInfoType[]
|
|||
// total:string
|
|||
// };
|
|||
// msg: string;
|
|||
list:any[] |
|||
total:string |
|||
}; |
|||
|
|||
/** 全局通过表格查询返回结果 */ |
|||
type TableListResult<T = any> = { |
|||
list: T; |
|||
pagination?: PaginationResult; |
|||
}; |
|||
|
|||
/** 全局通用表格分页返回数据结构 */ |
|||
type PaginationResult = { |
|||
page: number; |
|||
size: number; |
|||
total: number; |
|||
}; |
|||
|
|||
/** 全局通用表格分页请求参数 */ |
|||
type PageParams<T = any> = { |
|||
limit?: number; |
|||
page?: number; |
|||
} & { |
|||
[P in keyof T]?: T[P]; |
|||
}; |
|||
|
|||
type ErrorResponse = { |
|||
/** 业务约定的错误码 */ |
|||
errorCode: string; |
|||
/** 业务上的错误信息 */ |
|||
errorMessage?: string; |
|||
/** 业务上的请求是否成功 */ |
|||
success?: boolean; |
|||
}; |
|||
} |
|||
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 760 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 853 B |
|
After Width: | Height: | Size: 800 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 919 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 366 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1000 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 571 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@ -0,0 +1,3 @@ |
|||
export { createContextMenu, destroyContextMenu } from './src/createContextMenu'; |
|||
|
|||
export * from './src/typing'; |
|||
@ -0,0 +1,209 @@ |
|||
<script lang="tsx"> |
|||
import type { ContextMenuItem, ItemContentProps, Axis } from './typing'; |
|||
import type { FunctionalComponent, CSSProperties, PropType } from 'vue'; |
|||
import { defineComponent, nextTick, onMounted, computed, ref, unref, onUnmounted } from 'vue'; |
|||
// import Icon from '/@/components/Icon'; |
|||
import { Menu, Divider } from 'ant-design-vue'; |
|||
|
|||
const prefixCls = 'context-menu'; |
|||
|
|||
const props = { |
|||
width: { type: Number, default: 156 }, |
|||
customEvent: { type: Object as PropType<Event>, default: null }, |
|||
styles: { type: Object as PropType<CSSProperties> }, |
|||
showIcon: { type: Boolean, default: true }, |
|||
axis: { |
|||
// The position of the right mouse button click |
|||
type: Object as PropType<Axis>, |
|||
default() { |
|||
return { x: 0, y: 0 }; |
|||
}, |
|||
}, |
|||
items: { |
|||
// The most important list, if not, will not be displayed |
|||
type: Array as PropType<ContextMenuItem[]>, |
|||
default() { |
|||
return []; |
|||
}, |
|||
}, |
|||
}; |
|||
|
|||
const ItemContent: FunctionalComponent<ItemContentProps> = (props) => { |
|||
const { item } = props; |
|||
return ( |
|||
<span |
|||
style="display: inline-block; width: 100%; " |
|||
class="px-4" |
|||
onClick={props.handler.bind(null, item)} |
|||
> |
|||
{/* {props.showIcon && item.icon && <Icon class="mr-2" icon={item.icon} />} */} |
|||
<span>{item.label}</span> |
|||
</span> |
|||
); |
|||
}; |
|||
|
|||
export default defineComponent({ |
|||
name: 'ContextMenu', |
|||
props, |
|||
setup(props) { |
|||
const wrapRef = ref(null); |
|||
const showRef = ref(false); |
|||
|
|||
const getStyle = computed((): CSSProperties => { |
|||
const { axis, items, styles, width } = props; |
|||
const { x, y } = axis || { x: 0, y: 0 }; |
|||
const menuHeight = (items || []).length * 40; |
|||
const menuWidth = width; |
|||
const body = document.body; |
|||
|
|||
const left = body.clientWidth < x + menuWidth ? x - menuWidth : x; |
|||
const top = body.clientHeight < y + menuHeight ? y - menuHeight : y; |
|||
return { |
|||
...styles, |
|||
position: 'absolute', |
|||
width: `${width}px`, |
|||
left: `${left + 1}px`, |
|||
top: `${top + 1}px`, |
|||
zIndex: 9999, |
|||
}; |
|||
}); |
|||
|
|||
onMounted(() => { |
|||
nextTick(() => (showRef.value = true)); |
|||
}); |
|||
|
|||
onUnmounted(() => { |
|||
const el = unref(wrapRef); |
|||
el && document.body.removeChild(el); |
|||
}); |
|||
|
|||
function handleAction(item: ContextMenuItem, e: MouseEvent) { |
|||
const { handler, disabled } = item; |
|||
if (disabled) { |
|||
return; |
|||
} |
|||
showRef.value = false; |
|||
e?.stopPropagation(); |
|||
e?.preventDefault(); |
|||
handler?.(); |
|||
} |
|||
|
|||
function renderMenuItem(items: ContextMenuItem[]) { |
|||
const visibleItems = items.filter((item) => !item.hidden); |
|||
return visibleItems.map((item) => { |
|||
const { disabled, label, children, divider = false } = item; |
|||
|
|||
const contentProps = { |
|||
item, |
|||
handler: handleAction, |
|||
showIcon: props.showIcon, |
|||
}; |
|||
|
|||
if (!children || children.length === 0) { |
|||
return ( |
|||
<> |
|||
<Menu.Item disabled={disabled} class={`${prefixCls}__item`} key={label}> |
|||
<ItemContent {...contentProps} /> |
|||
</Menu.Item> |
|||
{divider ? <Divider key={`d-${label}`} /> : null} |
|||
</> |
|||
); |
|||
} |
|||
if (!unref(showRef)) return null; |
|||
|
|||
return ( |
|||
<Menu.SubMenu key={label} disabled={disabled} popupClassName={`${prefixCls}__popup`}> |
|||
{{ |
|||
title: () => <ItemContent {...contentProps} />, |
|||
default: () => renderMenuItem(children), |
|||
}} |
|||
</Menu.SubMenu> |
|||
); |
|||
}); |
|||
} |
|||
return () => { |
|||
if (!unref(showRef)) { |
|||
return null; |
|||
} |
|||
const { items } = props; |
|||
return ( |
|||
<div class={prefixCls}> |
|||
<Menu inlineIndent={12} mode="vertical" ref={wrapRef} style={unref(getStyle)}> |
|||
{renderMenuItem(items)} |
|||
</Menu> |
|||
</div> |
|||
); |
|||
}; |
|||
}, |
|||
}); |
|||
</script> |
|||
<style lang="less"> |
|||
@default-height: 42px !important; |
|||
|
|||
@small-height: 36px !important; |
|||
|
|||
@large-height: 36px !important; |
|||
|
|||
.item-style() { |
|||
li { |
|||
display: inline-block; |
|||
width: 100%; |
|||
height: @default-height; |
|||
margin: 0 !important; |
|||
line-height: @default-height; |
|||
|
|||
span { |
|||
line-height: @default-height; |
|||
} |
|||
|
|||
> div { |
|||
margin: 0 !important; |
|||
} |
|||
|
|||
&:not(.ant-menu-item-disabled):hover { |
|||
color: #c9d1d9; |
|||
background-color: @item-hover-bg; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.context-menu { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
z-index: 200; |
|||
display: block; |
|||
width: 156px; |
|||
margin: 0; |
|||
list-style: none; |
|||
background-color: #fff; |
|||
border: 1px solid rgb(0 0 0 / 8%); |
|||
border-radius: 0.25rem; |
|||
box-shadow: 0 2px 2px 0 rgb(0 0 0 / 14%), 0 3px 1px -2px rgb(0 0 0 / 10%), |
|||
0 1px 5px 0 rgb(0 0 0 / 6%); |
|||
background-clip: padding-box; |
|||
user-select: none; |
|||
|
|||
&__item { |
|||
margin: 0 !important; |
|||
} |
|||
.item-style(); |
|||
|
|||
.ant-divider { |
|||
margin: 0; |
|||
} |
|||
|
|||
&__popup { |
|||
.ant-divider { |
|||
margin: 0; |
|||
} |
|||
|
|||
.item-style(); |
|||
} |
|||
|
|||
.ant-menu-submenu-title, |
|||
.ant-menu-item { |
|||
padding: 0 !important; |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,83 @@ |
|||
import contextMenuVue from './ContextMenu.vue'; |
|||
import { isClient } from '@/utils/is'; |
|||
import { CreateContextOptions, ContextMenuProps } from './typing'; |
|||
import { createVNode, render } from 'vue'; |
|||
// import { usePermission } from '@/hooks/web/usePermission';
|
|||
import {hasPermission} from '@/utils/permission/hasPermission'; |
|||
const menuManager: { |
|||
domList: Element[]; |
|||
resolve: Fn; |
|||
} = { |
|||
domList: [], |
|||
resolve: () => {}, |
|||
}; |
|||
|
|||
export const createContextMenu = function (options: CreateContextOptions) { |
|||
const { event } = options || {}; |
|||
|
|||
event && event?.preventDefault(); |
|||
|
|||
if (!isClient) { |
|||
return; |
|||
} |
|||
return new Promise((resolve) => { |
|||
const body = document.body; |
|||
|
|||
const container = document.createElement('div'); |
|||
const propsData: Partial<ContextMenuProps> = {}; |
|||
if (options.styles) { |
|||
propsData.styles = options.styles; |
|||
} |
|||
|
|||
if (options.items) { |
|||
// const { isPermission } = usePermission();
|
|||
propsData.items = options.items.filter((item) => { |
|||
return hasPermission(item.auth as string); |
|||
// return true;
|
|||
}); |
|||
console.log('propsData.items: ', propsData.items); |
|||
|
|||
} |
|||
|
|||
if (options.event) { |
|||
propsData.customEvent = event; |
|||
propsData.axis = { x: event.clientX, y: event.clientY }; |
|||
} |
|||
|
|||
const vm = createVNode(contextMenuVue, propsData); |
|||
console.log('propsData: ', propsData); |
|||
render(vm, container); |
|||
|
|||
const handleClick = function () { |
|||
menuManager.resolve(''); |
|||
}; |
|||
|
|||
menuManager.domList.push(container); |
|||
|
|||
const remove = function () { |
|||
menuManager.domList.forEach((dom: Element) => { |
|||
try { |
|||
dom && body.removeChild(dom); |
|||
} catch (error) {} |
|||
}); |
|||
body.removeEventListener('click', handleClick); |
|||
body.removeEventListener('scroll', handleClick); |
|||
}; |
|||
|
|||
menuManager.resolve = function (arg) { |
|||
remove(); |
|||
resolve(arg); |
|||
}; |
|||
remove(); |
|||
body.appendChild(container); |
|||
body.addEventListener('click', handleClick); |
|||
body.addEventListener('scroll', handleClick); |
|||
}); |
|||
}; |
|||
|
|||
export const destroyContextMenu = function () { |
|||
if (menuManager) { |
|||
menuManager.resolve(''); |
|||
menuManager.domList = []; |
|||
} |
|||
}; |
|||
@ -0,0 +1,40 @@ |
|||
import { PermModeEnum, RoleEnum } from '@/enums/roleEnum'; |
|||
|
|||
export interface Axis { |
|||
x: number; |
|||
y: number; |
|||
} |
|||
|
|||
export interface ContextMenuItem { |
|||
label: string; |
|||
icon?: string; |
|||
hidden?: boolean; |
|||
disabled?: boolean; |
|||
handler?: Fn; |
|||
divider?: boolean; |
|||
children?: ContextMenuItem[]; |
|||
auth?: string | string[] | RoleEnum | RoleEnum[]; |
|||
authMode?: PermModeEnum; |
|||
} |
|||
export interface CreateContextOptions { |
|||
event: MouseEvent; |
|||
icon?: string; |
|||
styles?: any; |
|||
items?: ContextMenuItem[]; |
|||
} |
|||
|
|||
export interface ContextMenuProps { |
|||
event?: MouseEvent; |
|||
styles?: any; |
|||
items: ContextMenuItem[]; |
|||
customEvent?: MouseEvent; |
|||
axis?: Axis; |
|||
width?: number; |
|||
showIcon?: boolean; |
|||
} |
|||
|
|||
export interface ItemContentProps { |
|||
showIcon: boolean | undefined; |
|||
item: ContextMenuItem; |
|||
handler: Fn; |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export { default as BasicArrow } from './index.vue'; |
|||
@ -0,0 +1,24 @@ |
|||
<template> |
|||
<DownOutlined class="collapse-icon" /> |
|||
</template> |
|||
|
|||
<script lang="ts" setup> |
|||
import { computed } from 'vue'; |
|||
import { DownOutlined } from '@ant-design/icons-vue'; |
|||
|
|||
const props = defineProps({ |
|||
expand: { type: Boolean }, |
|||
}); |
|||
|
|||
/** |
|||
* @description 展开/收起 图标旋转转数 |
|||
*/ |
|||
const turn = computed(() => `${props.expand ? 0 : 0.5}turn`); |
|||
</script> |
|||
|
|||
<style lang="less" scoped> |
|||
.collapse-icon { |
|||
transform: rotate(v-bind(turn)); |
|||
transition: transform 0.3s; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,94 @@ |
|||
<script lang="tsx"> |
|||
import { defineComponent, computed, unref } from 'vue'; |
|||
import { InfoCircleOutlined } from '@ant-design/icons-vue'; |
|||
import { Tooltip } from 'ant-design-vue'; |
|||
import type { CSSProperties, PropType } from 'vue'; |
|||
import { isString, isArray } from '@/utils/is'; |
|||
import { getSlot } from '@/utils/helper/tsxHelper'; |
|||
|
|||
const props = { |
|||
/** |
|||
* Help text max-width |
|||
* @default: 600px |
|||
*/ |
|||
maxWidth: { type: String, default: '600px' }, |
|||
/** |
|||
* Whether to display the serial number |
|||
* @default: false |
|||
*/ |
|||
showIndex: { type: Boolean }, |
|||
/** |
|||
* Help text font color |
|||
* @default: #ffffff |
|||
*/ |
|||
color: { type: String, default: '#ffffff' }, |
|||
/** |
|||
* Help text font size |
|||
* @default: 14px |
|||
*/ |
|||
fontSize: { type: String, default: '14px' }, |
|||
/** |
|||
* Help text list |
|||
*/ |
|||
placement: { type: String, default: 'right' }, |
|||
/** |
|||
* Help text list |
|||
*/ |
|||
text: { type: [Array, String] as PropType<string[] | string> }, |
|||
}; |
|||
|
|||
export default defineComponent({ |
|||
name: 'BasicHelp', |
|||
components: { Tooltip }, |
|||
props, |
|||
setup(props, { slots }) { |
|||
const getTooltipStyle = computed( |
|||
(): CSSProperties => ({ color: props.color, fontSize: props.fontSize }), |
|||
); |
|||
|
|||
const getOverlayStyle = computed((): CSSProperties => ({ maxWidth: props.maxWidth })); |
|||
|
|||
function renderTitle() { |
|||
const textList = props.text; |
|||
|
|||
if (isString(textList)) { |
|||
return <p>{textList}</p>; |
|||
} |
|||
|
|||
if (isArray(textList)) { |
|||
return textList.map((text, index) => { |
|||
return ( |
|||
<p key={text}> |
|||
<> |
|||
{props.showIndex ? `${index + 1}. ` : ''} |
|||
{text} |
|||
</> |
|||
</p> |
|||
); |
|||
}); |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
return () => { |
|||
return ( |
|||
<Tooltip |
|||
overlayClassName="basic-help__wrap" |
|||
title={<div style={unref(getTooltipStyle)}>{renderTitle()}</div>} |
|||
autoAdjustOverflow={true} |
|||
overlayStyle={unref(getOverlayStyle)} |
|||
placement={props.placement as 'right'} |
|||
> |
|||
<span class="basic-help">{getSlot(slots) || <InfoCircleOutlined />}</span> |
|||
</Tooltip> |
|||
); |
|||
}; |
|||
}, |
|||
}); |
|||
</script> |
|||
|
|||
<style lang="less"> |
|||
.basic-help__wrap p { |
|||
margin-bottom: 0; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,16 @@ |
|||
import buttonProps from 'ant-design-vue/es/button/buttonTypes'; |
|||
import type { ButtonType as AButtonType } from 'ant-design-vue/es/button/buttonTypes'; |
|||
import type { ExtractPropTypes } from 'vue'; |
|||
|
|||
export type ButtonType = AButtonType | 'warning' | 'success'; |
|||
|
|||
export declare type ButtonProps = Partial<ExtractPropTypes<typeof buttonProps>>; |
|||
|
|||
export const typeColorMap = { |
|||
warning: 'var(--ant-warning-color)', |
|||
success: 'var(--ant-success-color)', |
|||
} as const; |
|||
|
|||
export const buttonTypes = ['default', 'primary', 'ghost', 'dashed', 'link', 'text']; |
|||
|
|||
export { buttonProps }; |
|||
@ -0,0 +1,48 @@ |
|||
<template> |
|||
<Button |
|||
v-bind="{ ...$attrs, ...props }" |
|||
:type="buttonType" |
|||
:class="[`ant-btn-${type}`, { 'basic-btn': colorVar }]" |
|||
> |
|||
<template v-for="(_, key) in $slots" #[key]> |
|||
<slot :name="key"></slot> |
|||
</template> |
|||
</Button> |
|||
</template> |
|||
<script lang="ts" setup> |
|||
import { computed, type ComputedRef } from 'vue'; |
|||
import { Button } from 'ant-design-vue'; |
|||
import { buttonProps, typeColorMap, buttonTypes } from './button'; |
|||
import type { ButtonType } from './button'; |
|||
import type { ButtonType as AButtonType } from 'ant-design-vue/es/button'; |
|||
|
|||
const props = defineProps({ |
|||
...buttonProps(), |
|||
type: { |
|||
type: String as PropType<ButtonType>, |
|||
}, |
|||
// 自定义按钮颜色 |
|||
color: String, |
|||
}); |
|||
|
|||
const buttonType = computed(() => { |
|||
const type = props.type!; |
|||
return buttonTypes.includes(type) |
|||
? (type as ButtonType) |
|||
: Reflect.has(typeColorMap, type) || props.color |
|||
? 'primary' |
|||
: 'default'; |
|||
}) as ComputedRef<AButtonType>; |
|||
|
|||
const colorVar = computed(() => { |
|||
return props.color || typeColorMap[props.type!]; |
|||
}); |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.basic-btn { |
|||
--ant-primary-color: v-bind(colorVar); |
|||
--ant-primary-color-hover: v-bind(colorVar); |
|||
--ant-primary-color-active: v-bind(colorVar); |
|||
} |
|||
</style> |
|||
@ -0,0 +1,9 @@ |
|||
import AButton from './button.vue'; |
|||
|
|||
export default AButton; |
|||
|
|||
export const Button = AButton; |
|||
|
|||
export * from './button'; |
|||
|
|||
export { AButton }; |
|||
@ -0,0 +1,54 @@ |
|||
<template> |
|||
<Checkbox v-bind="getProps" v-model:checked="checkedModel" @change="handleChange"> |
|||
<slot></slot> |
|||
</Checkbox> |
|||
</template> |
|||
|
|||
<script lang="ts" setup> |
|||
import { computed } from 'vue'; |
|||
import { checkboxProps } from 'ant-design-vue/es/checkbox'; |
|||
import { omit } from 'lodash-es'; |
|||
import { Checkbox } from 'ant-design-vue'; |
|||
|
|||
defineOptions({ |
|||
inheritAttrs: false, |
|||
}); |
|||
|
|||
const props = defineProps({ |
|||
...checkboxProps(), |
|||
trueValue: { |
|||
type: [Number, Boolean, String], |
|||
default: true, |
|||
}, |
|||
falseValue: { |
|||
type: [Number, Boolean, String], |
|||
default: false, |
|||
}, |
|||
}); |
|||
|
|||
const emit = defineEmits(['update:checked', 'change']); |
|||
|
|||
const getProps = computed(() => { |
|||
return omit(props, ['onUpdate:checked', 'onChange']); |
|||
}); |
|||
|
|||
const checkedModel = computed<boolean>({ |
|||
get() { |
|||
return props.checked === props.trueValue; |
|||
}, |
|||
set(val) { |
|||
emit('update:checked', val ? props.trueValue : props.falseValue); |
|||
}, |
|||
}); |
|||
|
|||
const handleChange = (e) => { |
|||
const evt = { |
|||
...e, |
|||
target: { |
|||
...e.target, |
|||
checked: e.target.checked ? props.trueValue : props.falseValue, |
|||
}, |
|||
}; |
|||
emit('change', evt); |
|||
}; |
|||
</script> |
|||
@ -0,0 +1,72 @@ |
|||
import { defineComponent, unref, computed } from 'vue'; |
|||
import { createFromIconfontCN } from '@ant-design/icons-vue'; |
|||
import type { PropType } from 'vue'; |
|||
import { isString } from '@/utils/is'; |
|||
|
|||
let scriptUrls = [`${import.meta.env.BASE_URL}iconfont.js`]; |
|||
|
|||
let MyIconFont = createFromIconfontCN({ |
|||
// scriptUrl: '//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js',
|
|||
// scriptUrl: '//at.alicdn.com/t/font_2184398_zflo1kjcemp.js',
|
|||
// iconfont字体图标本地化,详见:/public/iconfont.js
|
|||
scriptUrl: scriptUrls, |
|||
}); |
|||
|
|||
export default defineComponent({ |
|||
name: 'IconFont', |
|||
props: { |
|||
type: { |
|||
type: String as PropType<string>, |
|||
default: '', |
|||
}, |
|||
prefix: { |
|||
type: String, |
|||
default: 'icon-', |
|||
}, |
|||
color: { |
|||
type: String as PropType<string>, |
|||
default: 'unset', |
|||
}, |
|||
size: { |
|||
type: [Number, String] as PropType<number | string>, |
|||
default: 14, |
|||
}, |
|||
scriptUrl: { |
|||
// 阿里图库字体图标路径
|
|||
type: String as PropType<string | string[]>, |
|||
default: '', |
|||
}, |
|||
}, |
|||
setup(props, { attrs }) { |
|||
// 如果外部传进来字体图标路径,则覆盖默认的
|
|||
if (props.scriptUrl) { |
|||
scriptUrls = [...new Set(scriptUrls.concat(props.scriptUrl))]; |
|||
MyIconFont = createFromIconfontCN({ |
|||
scriptUrl: scriptUrls, |
|||
}); |
|||
} |
|||
|
|||
const wrapStyleRef = computed(() => { |
|||
const { color, size } = props; |
|||
|
|||
const fs = isString(size) ? parseFloat(size) : size; |
|||
|
|||
return { |
|||
color, |
|||
fontSize: `${fs}px`, |
|||
}; |
|||
}); |
|||
|
|||
return () => { |
|||
const { type, prefix } = props; |
|||
|
|||
return type ? ( |
|||
<MyIconFont |
|||
type={type.startsWith(prefix) ? type : `${prefix}${type}`} |
|||
{...attrs} |
|||
style={unref(wrapStyleRef)} |
|||
/> |
|||
) : null; |
|||
}; |
|||
}, |
|||
}); |
|||
@ -0,0 +1,2 @@ |
|||
import IconFont from './icon-font'; |
|||
export { IconFont }; |
|||
@ -0,0 +1,71 @@ |
|||
<template> |
|||
<Popover v-model:visible="visible" placement="bottomLeft" trigger="focus"> |
|||
<template #content> |
|||
<div class="select-box"> |
|||
<template v-for="iconItem in glyphs" :key="iconItem.font_class"> |
|||
<div |
|||
:title="iconItem.name" |
|||
class="select-box-item" |
|||
:class="{ active: modelValue?.replace('icon-', '') == iconItem.font_class }" |
|||
@click="selectIcon(iconItem)" |
|||
> |
|||
<icon-font :type="iconItem.font_class" size="20" /> |
|||
</div> |
|||
</template> |
|||
</div> |
|||
</template> |
|||
<a-input v-model:value="modelValue" :placeholder="placeholder"> |
|||
<template v-if="modelValue" #prefix> |
|||
<icon-font :type="modelValue" size="22" /> |
|||
</template> |
|||
</a-input> |
|||
</Popover> |
|||
</template> |
|||
<script lang="ts" setup> |
|||
import { ref } from 'vue'; |
|||
import { useVModel } from '@vueuse/core'; |
|||
import { Popover } from 'ant-design-vue'; |
|||
import icons from './icons.json'; |
|||
import { IconFont } from '@/components/basic/iconfont'; |
|||
|
|||
const { glyphs } = icons; |
|||
|
|||
interface Props { |
|||
value: string; |
|||
placeholder?: string; |
|||
} |
|||
|
|||
interface Emits { |
|||
(e: 'update:value', val: string): void; |
|||
} |
|||
|
|||
const props = withDefaults(defineProps<Props>(), { |
|||
value: '', |
|||
placeholder: '请选择', |
|||
}); |
|||
|
|||
const emit = defineEmits<Emits>(); |
|||
|
|||
const visible = ref(false); |
|||
const modelValue = useVModel(props, 'value', emit); |
|||
|
|||
const selectIcon = (iconItem: typeof glyphs[number]) => { |
|||
modelValue.value = iconItem.font_class; |
|||
visible.value = false; |
|||
}; |
|||
</script> |
|||
<style lang="less" scoped> |
|||
.select-box { |
|||
@apply grid grid-cols-9 h-300px overflow-auto; |
|||
|
|||
&-item { |
|||
@apply flex m-2px p-6px; |
|||
border: 1px solid #e5e7eb; |
|||
|
|||
&:hover, |
|||
&.active { |
|||
@apply border-blue-600; |
|||
} |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1 @@ |
|||
export { default as LocalePicker } from './index.vue'; |
|||
@ -0,0 +1,60 @@ |
|||
<template> |
|||
<Dropdown placement="bottomRight"> |
|||
<SvgIcon name="locale" /> |
|||
<span v-if="showText" class="ml-1">{{ getLocaleText }}</span> |
|||
<template #overlay> |
|||
<Menu v-model:selectedKeys="selectedKeys" @click="handleMenuClick"> |
|||
<Menu.Item v-for="item in localeList" :key="item.lang"> |
|||
<a href="javascript:;">{{ item.icon }} {{ item.label }}</a> |
|||
</Menu.Item> |
|||
</Menu> |
|||
</template> |
|||
</Dropdown> |
|||
</template> |
|||
<script lang="ts" setup> |
|||
import { ref, watchEffect, unref, computed } from 'vue'; |
|||
import { Dropdown, Menu } from 'ant-design-vue'; |
|||
import { useLocale } from '@/locales/useLocale'; |
|||
import { type LocaleType, localeList } from '@/locales/config'; |
|||
import { SvgIcon } from '@/components/basic/svg-icon'; |
|||
|
|||
const props = defineProps({ |
|||
/** |
|||
* Whether to display text |
|||
*/ |
|||
showText: { type: Boolean, default: true }, |
|||
/** |
|||
* Whether to refresh the interface when changing |
|||
*/ |
|||
reload: { type: Boolean }, |
|||
}); |
|||
|
|||
const selectedKeys = ref<string[]>([]); |
|||
|
|||
const { changeLocale, getLocale } = useLocale(); |
|||
|
|||
const getLocaleText = computed(() => { |
|||
const key = selectedKeys.value[0]; |
|||
if (!key) { |
|||
return ''; |
|||
} |
|||
return localeList.find((item) => item.lang === key)?.label; |
|||
}); |
|||
|
|||
watchEffect(() => { |
|||
selectedKeys.value = [unref(getLocale)]; |
|||
}); |
|||
|
|||
async function toggleLocale(lang: LocaleType | string) { |
|||
await changeLocale(lang as LocaleType); |
|||
selectedKeys.value = [lang as string]; |
|||
props.reload && location.reload(); |
|||
} |
|||
|
|||
function handleMenuClick({ key }) { |
|||
if (unref(getLocale) === key) { |
|||
return; |
|||
} |
|||
toggleLocale(key as string); |
|||
} |
|||
</script> |
|||
@ -0,0 +1,3 @@ |
|||
import SplitPanel from './index.vue'; |
|||
|
|||
export { SplitPanel }; |
|||
@ -0,0 +1,102 @@ |
|||
<template> |
|||
<div class="split-wrapper"> |
|||
<div ref="scalable" class="scalable"> |
|||
<div class="left-content"> |
|||
<slot name="left-content"> 右边内容区 </slot> |
|||
</div> |
|||
<div ref="separator" class="separator" @mousedown="startDrag"><i></i><i></i></div> |
|||
</div> |
|||
<div class="right-content"> |
|||
<slot name="right-content"> 右边内容区 </slot> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref } from 'vue'; |
|||
|
|||
import { throttle } from 'lodash-es'; |
|||
|
|||
const scalable = ref<HTMLDivElement>(); |
|||
|
|||
let startX: number; |
|||
let startWidth: number; |
|||
|
|||
// 拖拽中 |
|||
// @throttle(20) |
|||
const onDrag = throttle(function (e: MouseEvent) { |
|||
scalable.value && (scalable.value.style.width = `${startWidth + e.clientX - startX}px`); |
|||
}, 20); |
|||
|
|||
// 拖拽结束 |
|||
const dragEnd = () => { |
|||
document.documentElement.style.userSelect = 'unset'; |
|||
document.documentElement.removeEventListener('mousemove', onDrag); |
|||
document.documentElement.removeEventListener('mouseup', dragEnd); |
|||
}; |
|||
|
|||
// 鼠标按下 |
|||
const startDrag = (e: MouseEvent) => { |
|||
startX = e.clientX; |
|||
scalable.value && (startWidth = parseInt(window.getComputedStyle(scalable.value).width, 10)); |
|||
|
|||
document.documentElement.style.userSelect = 'none'; |
|||
document.documentElement.addEventListener('mousemove', onDrag); |
|||
document.documentElement.addEventListener('mouseup', dragEnd); |
|||
}; |
|||
</script> |
|||
|
|||
<style lang="less"> |
|||
@import '@/styles/theme.less'; |
|||
|
|||
@classNames: split-wrapper, separator; |
|||
.themeBgColor(@classNames); |
|||
|
|||
.split-wrapper { |
|||
display: flex; |
|||
width: 100%; |
|||
height: 100%; |
|||
|
|||
.scalable { |
|||
position: relative; |
|||
width: 240px; |
|||
max-width: 50vw; |
|||
min-width: 100px; |
|||
overflow: auto; |
|||
|
|||
.left-content { |
|||
height: 100%; |
|||
padding: 12px 20px 12px 12px; |
|||
} |
|||
|
|||
.separator { |
|||
position: absolute; |
|||
top: 0; |
|||
right: 0; |
|||
display: flex; |
|||
width: 14px; |
|||
height: 100%; |
|||
cursor: col-resize; |
|||
box-shadow: -4px -2px 4px -5px rgba(0, 0, 0, 0.35), 4px 3px 4px -5px rgba(0, 0, 0, 0.35); |
|||
align-items: center; |
|||
justify-content: center; |
|||
|
|||
i { |
|||
width: 1px; |
|||
height: 14px; |
|||
margin: 0 1px; |
|||
background-color: #e9e9e9; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.right-content { |
|||
flex: 1; |
|||
} |
|||
|
|||
.left-content, |
|||
.right-content { |
|||
overflow: auto; |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,3 @@ |
|||
import SvgIcon from './svg-icon.vue'; |
|||
|
|||
export { SvgIcon }; |
|||
@ -0,0 +1,50 @@ |
|||
<template> |
|||
<svg v-bind="$attrs" class="svg-icon" :style="getStyle" aria-hidden="true"> |
|||
<use :xlink:href="symbolId" /> |
|||
</svg> |
|||
</template> |
|||
|
|||
<script lang="ts" setup> |
|||
import { computed, type CSSProperties } from 'vue'; |
|||
defineOptions({ |
|||
name: 'svg-icon', |
|||
}); |
|||
|
|||
const props = defineProps({ |
|||
prefix: { |
|||
type: String, |
|||
default: 'svg-icon', |
|||
}, |
|||
name: { |
|||
type: String, |
|||
required: true, |
|||
}, |
|||
size: { |
|||
type: [Number, String], |
|||
default: 16, |
|||
}, |
|||
color:{ |
|||
type: String, |
|||
required: false, |
|||
} |
|||
}); |
|||
const symbolId = computed(() => `#${props.prefix}-${props.name}`); |
|||
const getStyle = computed((): CSSProperties => { |
|||
const { size,color } = props; |
|||
const s = `${size}`.replace('px', '').concat('px'); |
|||
return { |
|||
width: s, |
|||
height: s, |
|||
color |
|||
}; |
|||
}); |
|||
</script> |
|||
|
|||
<style lang="less"> |
|||
.svg-icon { |
|||
overflow: hidden; |
|||
vertical-align: -0.15em; |
|||
fill: currentColor; |
|||
cursor: pointer; |
|||
} |
|||
</style> |
|||
@ -0,0 +1 @@ |
|||
export { default as TitleI18n } from './index.vue'; |
|||
@ -0,0 +1,26 @@ |
|||
<template> |
|||
<i18n-t tag="span" :keypath="getTitle" scope="global" /> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { type PropType, computed } from 'vue'; |
|||
import { useLocaleStore } from '@/store/modules/locale'; |
|||
|
|||
const props = defineProps({ |
|||
title: { |
|||
type: [String, Object] as PropType<string | Title18n>, |
|||
required: true, |
|||
default: '', |
|||
}, |
|||
}); |
|||
|
|||
const localeStore = useLocaleStore(); |
|||
|
|||
const getTitle = computed(() => { |
|||
const { title = '' } = props; |
|||
if (typeof title === 'object') { |
|||
return title?.[localeStore.locale] ?? title; |
|||
} |
|||
return title; |
|||
}); |
|||
</script> |
|||
@ -0,0 +1,6 @@ |
|||
import { withInstall } from '@/utils'; |
|||
import collapseContainer from './src/collapse/CollapseContainer.vue'; |
|||
|
|||
export const CollapseContainer = withInstall<any>(collapseContainer); |
|||
|
|||
export * from './src/typing'; |
|||
@ -0,0 +1,109 @@ |
|||
<template> |
|||
<div :class="prefixCls"> |
|||
<CollapseHeader v-bind="props" :prefixCls="prefixCls" :show="show" @expand="handleExpand"> |
|||
<template #title> |
|||
<slot name="title"></slot> |
|||
</template> |
|||
<template #action> |
|||
<slot name="action"></slot> |
|||
</template> |
|||
</CollapseHeader> |
|||
|
|||
<div class="p-2"> |
|||
<CollapseTransition :enable="canExpan"> |
|||
<Skeleton v-if="loading" :active="loading" /> |
|||
<div :class="`${prefixCls}__body`" v-else v-show="show"> |
|||
<slot></slot> |
|||
</div> |
|||
</CollapseTransition> |
|||
</div> |
|||
<div :class="`${prefixCls}__footer`" v-if="$slots.footer"> |
|||
<slot name="footer"></slot> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
<script lang="ts" setup> |
|||
import type { PropType } from 'vue'; |
|||
import { ref } from 'vue'; |
|||
import { isNil } from 'lodash-es'; |
|||
// component |
|||
import { Skeleton } from 'ant-design-vue'; |
|||
import { CollapseTransition } from '@/components/core/Transition'; |
|||
import CollapseHeader from './CollapseHeader.vue'; |
|||
import { triggerWindowResize } from '@/utils/event'; |
|||
// hook |
|||
import { useTimeoutFn } from '@/hooks/useTimeout'; |
|||
|
|||
const props = defineProps({ |
|||
title: { type: String, default: '' }, |
|||
loading: { type: Boolean }, |
|||
/** |
|||
* Can it be expanded |
|||
*/ |
|||
canExpan: { type: Boolean, default: true }, |
|||
/** |
|||
* Warm reminder on the right side of the title |
|||
*/ |
|||
helpMessage: { |
|||
type: [Array, String] as PropType<string[] | string>, |
|||
default: '', |
|||
}, |
|||
/** |
|||
* Whether to trigger window.resize when expanding and contracting, |
|||
* Can adapt to tables and forms, when the form shrinks, the form triggers resize to adapt to the height |
|||
*/ |
|||
triggerWindowResize: { type: Boolean }, |
|||
/** |
|||
* Delayed loading time |
|||
*/ |
|||
lazyTime: { type: Number, default: 0 }, |
|||
}); |
|||
|
|||
const show = ref(true); |
|||
|
|||
const prefixCls = 'collapse-container'; |
|||
|
|||
/** |
|||
* @description: Handling development events |
|||
*/ |
|||
function handleExpand(val: boolean) { |
|||
show.value = isNil(val) ? !show.value : val; |
|||
if (props.triggerWindowResize) { |
|||
// 200 milliseconds here is because the expansion has animation, |
|||
useTimeoutFn(triggerWindowResize, 200); |
|||
} |
|||
} |
|||
|
|||
defineExpose({ |
|||
handleExpand, |
|||
}); |
|||
</script> |
|||
<style lang="less"> |
|||
@prefix-cls: ~'collapse-container'; |
|||
|
|||
.@{prefix-cls} { |
|||
background-color: @component-background; |
|||
border-radius: 2px; |
|||
transition: all 0.3s ease-in-out; |
|||
|
|||
&__header { |
|||
display: flex; |
|||
height: 32px; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
border-bottom: 1px solid #d9d9d9; |
|||
} |
|||
|
|||
&__footer { |
|||
border-top: 1px solid #d9d9d9; |
|||
} |
|||
|
|||
&__action { |
|||
display: flex; |
|||
text-align: right; |
|||
flex: 1; |
|||
align-items: center; |
|||
justify-content: flex-end; |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,40 @@ |
|||
<template> |
|||
<div :class="[`${prefixCls}__header px-2 py-5`, $attrs.class]"> |
|||
<!-- <BasicTitle :helpMessage="helpMessage" normal> --> |
|||
<template v-if="title"> |
|||
{{ title }} |
|||
</template> |
|||
<template v-else> |
|||
<slot name="title"></slot> |
|||
</template> |
|||
<!-- </BasicTitle> --> |
|||
<div :class="`${prefixCls}__action`"> |
|||
<slot name="action"></slot> |
|||
<BasicArrow v-if="canExpan" up :expand="show" @click="$emit('expand')" /> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
<script lang="ts"> |
|||
import { defineComponent } from 'vue'; |
|||
import { BasicArrow } from '@/components/basic/basic-arrow'; |
|||
|
|||
const props = { |
|||
prefixCls: { type: String }, |
|||
helpMessage: { |
|||
type: [Array, String] as PropType<string[] | string>, |
|||
default: '', |
|||
}, |
|||
title: { type: String }, |
|||
show: { type: Boolean }, |
|||
canExpan: { type: Boolean }, |
|||
}; |
|||
|
|||
export default defineComponent({ |
|||
components: { |
|||
BasicArrow |
|||
}, |
|||
inheritAttrs: false, |
|||
props, |
|||
emits: ['expand'], |
|||
}); |
|||
</script> |
|||
@ -0,0 +1,17 @@ |
|||
export type ScrollType = 'default' | 'main'; |
|||
|
|||
export interface CollapseContainerOptions { |
|||
canExpand?: boolean; |
|||
title?: string; |
|||
helpMessage?: Array<any> | string; |
|||
} |
|||
export interface ScrollContainerOptions { |
|||
enableScroll?: boolean; |
|||
type?: ScrollType; |
|||
} |
|||
|
|||
export type ScrollActionType = RefType<{ |
|||
scrollBottom: () => void; |
|||
getScrollWrap: () => Nullable<HTMLElement>; |
|||
scrollTo: (top: number) => void; |
|||
}>; |
|||
@ -0,0 +1,27 @@ |
|||
import { createSimpleTransition, createJavascriptTransition } from './src/CreateTransition'; |
|||
|
|||
import ExpandTransitionGenerator from './src/ExpandTransition'; |
|||
|
|||
export { default as CollapseTransition } from './src/CollapseTransition.vue'; |
|||
|
|||
export const FadeTransition = createSimpleTransition('fade-transition'); |
|||
export const ScaleTransition = createSimpleTransition('scale-transition'); |
|||
export const SlideYTransition = createSimpleTransition('slide-y-transition'); |
|||
export const ScrollYTransition = createSimpleTransition('scroll-y-transition'); |
|||
export const SlideYReverseTransition = createSimpleTransition('slide-y-reverse-transition'); |
|||
export const ScrollYReverseTransition = createSimpleTransition('scroll-y-reverse-transition'); |
|||
export const SlideXTransition = createSimpleTransition('slide-x-transition'); |
|||
export const ScrollXTransition = createSimpleTransition('scroll-x-transition'); |
|||
export const SlideXReverseTransition = createSimpleTransition('slide-x-reverse-transition'); |
|||
export const ScrollXReverseTransition = createSimpleTransition('scroll-x-reverse-transition'); |
|||
export const ScaleRotateTransition = createSimpleTransition('scale-rotate-transition'); |
|||
|
|||
export const ExpandXTransition = createJavascriptTransition( |
|||
'expand-x-transition', |
|||
ExpandTransitionGenerator('', true), |
|||
); |
|||
|
|||
export const ExpandTransition = createJavascriptTransition( |
|||
'expand-transition', |
|||
ExpandTransitionGenerator(''), |
|||
); |
|||
@ -0,0 +1,78 @@ |
|||
<template> |
|||
<transition mode="out-in" v-on="on"> |
|||
<slot></slot> |
|||
</transition> |
|||
</template> |
|||
<script lang="ts"> |
|||
import { defineComponent } from 'vue'; |
|||
import { addClass, removeClass } from '@/utils/domUtils'; |
|||
|
|||
export default defineComponent({ |
|||
name: 'CollapseTransition', |
|||
setup() { |
|||
return { |
|||
on: { |
|||
beforeEnter(el) { |
|||
addClass(el, 'collapse-transition'); |
|||
if (!el.dataset) el.dataset = {}; |
|||
|
|||
el.dataset.oldPaddingTop = el.style.paddingTop; |
|||
el.dataset.oldPaddingBottom = el.style.paddingBottom; |
|||
|
|||
el.style.height = '0'; |
|||
el.style.paddingTop = 0; |
|||
el.style.paddingBottom = 0; |
|||
}, |
|||
|
|||
enter(el) { |
|||
el.dataset.oldOverflow = el.style.overflow; |
|||
if (el.scrollHeight !== 0) { |
|||
el.style.height = el.scrollHeight + 'px'; |
|||
el.style.paddingTop = el.dataset.oldPaddingTop; |
|||
el.style.paddingBottom = el.dataset.oldPaddingBottom; |
|||
} else { |
|||
el.style.height = ''; |
|||
el.style.paddingTop = el.dataset.oldPaddingTop; |
|||
el.style.paddingBottom = el.dataset.oldPaddingBottom; |
|||
} |
|||
|
|||
el.style.overflow = 'hidden'; |
|||
}, |
|||
|
|||
afterEnter(el) { |
|||
removeClass(el, 'collapse-transition'); |
|||
el.style.height = ''; |
|||
el.style.overflow = el.dataset.oldOverflow; |
|||
}, |
|||
|
|||
beforeLeave(el) { |
|||
if (!el.dataset) el.dataset = {}; |
|||
el.dataset.oldPaddingTop = el.style.paddingTop; |
|||
el.dataset.oldPaddingBottom = el.style.paddingBottom; |
|||
el.dataset.oldOverflow = el.style.overflow; |
|||
|
|||
el.style.height = el.scrollHeight + 'px'; |
|||
el.style.overflow = 'hidden'; |
|||
}, |
|||
|
|||
leave(el) { |
|||
if (el.scrollHeight !== 0) { |
|||
addClass(el, 'collapse-transition'); |
|||
el.style.height = 0; |
|||
el.style.paddingTop = 0; |
|||
el.style.paddingBottom = 0; |
|||
} |
|||
}, |
|||
|
|||
afterLeave(el) { |
|||
removeClass(el, 'collapse-transition'); |
|||
el.style.height = ''; |
|||
el.style.overflow = el.dataset.oldOverflow; |
|||
el.style.paddingTop = el.dataset.oldPaddingTop; |
|||
el.style.paddingBottom = el.dataset.oldPaddingBottom; |
|||
}, |
|||
}, |
|||
}; |
|||
}, |
|||
}); |
|||
</script> |
|||
@ -0,0 +1,73 @@ |
|||
import type { PropType } from 'vue'; |
|||
|
|||
import { defineComponent, Transition, TransitionGroup } from 'vue'; |
|||
import { getSlot } from '@/utils/helper/tsxHelper'; |
|||
|
|||
type Mode = 'in-out' | 'out-in' | 'default' | undefined; |
|||
|
|||
export function createSimpleTransition(name: string, origin = 'top center 0', mode?: Mode) { |
|||
return defineComponent({ |
|||
name, |
|||
props: { |
|||
group: { |
|||
type: Boolean as PropType<boolean>, |
|||
default: false, |
|||
}, |
|||
mode: { |
|||
type: String as PropType<Mode>, |
|||
default: mode, |
|||
}, |
|||
origin: { |
|||
type: String as PropType<string>, |
|||
default: origin, |
|||
}, |
|||
}, |
|||
setup(props, { slots, attrs }) { |
|||
const onBeforeEnter = (el: HTMLElement) => { |
|||
el.style.transformOrigin = props.origin; |
|||
}; |
|||
|
|||
return () => { |
|||
const Tag = !props.group ? Transition : TransitionGroup; |
|||
return ( |
|||
<Tag name={name} mode={props.mode} {...attrs} onBeforeEnter={onBeforeEnter}> |
|||
{() => getSlot(slots)} |
|||
</Tag> |
|||
); |
|||
}; |
|||
}, |
|||
}); |
|||
} |
|||
export function createJavascriptTransition( |
|||
name: string, |
|||
functions: Recordable, |
|||
mode: Mode = 'in-out', |
|||
) { |
|||
return defineComponent({ |
|||
name, |
|||
props: { |
|||
mode: { |
|||
type: String as PropType<Mode>, |
|||
default: mode, |
|||
}, |
|||
}, |
|||
setup(props, { attrs, slots }) { |
|||
return () => { |
|||
return ( |
|||
<Transition |
|||
name={name} |
|||
mode={props.mode} |
|||
{...attrs} |
|||
onBeforeEnter={functions.beforeEnter} |
|||
onEnter={functions.enter} |
|||
onLeave={functions.leave} |
|||
onAfterLeave={functions.afterLeave} |
|||
onLeaveCancelled={functions.afterLeave} |
|||
> |
|||
{() => getSlot(slots)} |
|||
</Transition> |
|||
); |
|||
}; |
|||
}, |
|||
}); |
|||
} |
|||
@ -0,0 +1,89 @@ |
|||
/** |
|||
* Makes the first character of a string uppercase |
|||
*/ |
|||
export function upperFirst(str: string): string { |
|||
return str.charAt(0).toUpperCase() + str.slice(1); |
|||
} |
|||
|
|||
interface HTMLExpandElement extends HTMLElement { |
|||
_parent?: (Node & ParentNode & HTMLElement) | null; |
|||
_initialStyle: { |
|||
transition: string; |
|||
overflow: string | null; |
|||
height?: string | null; |
|||
width?: string | null; |
|||
}; |
|||
} |
|||
|
|||
export default function (expandedParentClass = '', x = false) { |
|||
const sizeProperty = x ? 'width' : ('height' as 'width' | 'height'); |
|||
const offsetProperty = `offset${upperFirst(sizeProperty)}` as 'offsetHeight' | 'offsetWidth'; |
|||
|
|||
return { |
|||
beforeEnter(el: HTMLExpandElement) { |
|||
el._parent = el.parentNode as (Node & ParentNode & HTMLElement) | null; |
|||
el._initialStyle = { |
|||
transition: el.style.transition, |
|||
overflow: el.style.overflow, |
|||
[sizeProperty]: el.style[sizeProperty], |
|||
}; |
|||
}, |
|||
|
|||
enter(el: HTMLExpandElement) { |
|||
const initialStyle = el._initialStyle; |
|||
|
|||
el.style.setProperty('transition', 'none', 'important'); |
|||
el.style.overflow = 'hidden'; |
|||
// const offset = `${el[offsetProperty]}px`;
|
|||
|
|||
// el.style[sizeProperty] = '0';
|
|||
|
|||
void el.offsetHeight; // force reflow
|
|||
|
|||
el.style.transition = initialStyle.transition; |
|||
|
|||
if (expandedParentClass && el._parent) { |
|||
el._parent.classList.add(expandedParentClass); |
|||
} |
|||
|
|||
requestAnimationFrame(() => { |
|||
// el.style[sizeProperty] = offset;
|
|||
}); |
|||
}, |
|||
|
|||
afterEnter: resetStyles, |
|||
enterCancelled: resetStyles, |
|||
|
|||
leave(el: HTMLExpandElement) { |
|||
el._initialStyle = { |
|||
transition: '', |
|||
overflow: el.style.overflow, |
|||
[sizeProperty]: el.style[sizeProperty], |
|||
}; |
|||
|
|||
el.style.overflow = 'hidden'; |
|||
el.style[sizeProperty] = `${el[offsetProperty]}px`; |
|||
/* eslint-disable-next-line */ |
|||
void el.offsetHeight; // force reflow
|
|||
|
|||
requestAnimationFrame(() => (el.style[sizeProperty] = '0')); |
|||
}, |
|||
|
|||
afterLeave, |
|||
leaveCancelled: afterLeave, |
|||
}; |
|||
|
|||
function afterLeave(el: HTMLExpandElement) { |
|||
if (expandedParentClass && el._parent) { |
|||
el._parent.classList.remove(expandedParentClass); |
|||
} |
|||
resetStyles(el); |
|||
} |
|||
|
|||
function resetStyles(el: HTMLExpandElement) { |
|||
const size = el._initialStyle[sizeProperty]; |
|||
el.style.overflow = el._initialStyle.overflow!; |
|||
if (size != null) el.style[sizeProperty] = size; |
|||
Reflect.deleteProperty(el, '_initialStyle'); |
|||
} |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
import BasicTree from './src/BasicTree.vue'; |
|||
import './style'; |
|||
|
|||
export { BasicTree }; |
|||
// export type { ContextMenuItem } from '/@/hooks/web/useContextMenu';
|
|||
export * from './src/types/tree'; |
|||
export { TreeIcon } from './src/TreeIcon'; |
|||
@ -0,0 +1,468 @@ |
|||
<script lang="tsx"> |
|||
import type { CSSProperties } from 'vue'; |
|||
import type { |
|||
FieldNames, |
|||
TreeState, |
|||
TreeItem, |
|||
KeyType, |
|||
CheckKeys, |
|||
TreeActionType, |
|||
} from './types/tree'; |
|||
|
|||
import { |
|||
defineComponent, |
|||
reactive, |
|||
computed, |
|||
unref, |
|||
ref, |
|||
watchEffect, |
|||
toRaw, |
|||
watch, |
|||
onMounted, |
|||
} from 'vue'; |
|||
import { Tree, Spin, Empty } from 'ant-design-vue'; |
|||
import { TreeIcon } from './TreeIcon'; |
|||
import { omit, get, difference, cloneDeep } from 'lodash-es'; |
|||
import { isArray, isBoolean, isEmpty, isFunction } from '@/utils/is'; |
|||
import { extendSlots, getSlot } from '@/utils/helper/tsxHelper'; |
|||
import { filter, treeToList, eachTree } from '@/utils/helper/treeHelper'; |
|||
import { useTree } from './hooks/useTree'; |
|||
import { treeEmits, treeProps } from './types/tree'; |
|||
import TreeHeader from './components/TreeHeader.vue'; |
|||
import { useContextMenu } from '@/hooks/useContextMenu'; |
|||
import { CreateContextOptions } from '@/components/basic/ContextMenu'; |
|||
import { hasPermission } from '@/utils/permission/hasPermission'; |
|||
export default defineComponent({ |
|||
name: 'BasicTree', |
|||
inheritAttrs: false, |
|||
props: treeProps, |
|||
emits: treeEmits, |
|||
setup(props, { attrs, slots, emit, expose }) { |
|||
const state = reactive<TreeState>({ |
|||
checkStrictly: props.checkStrictly, |
|||
expandedKeys: props.expandedKeys || [], |
|||
selectedKeys: props.selectedKeys || [], |
|||
checkedKeys: props.checkedKeys || [], |
|||
}); |
|||
|
|||
const searchState = reactive({ |
|||
startSearch: false, |
|||
searchText: '', |
|||
searchData: [] as TreeItem[], |
|||
}); |
|||
|
|||
const treeDataRef = ref<TreeItem[]>([]); |
|||
|
|||
const [createContextMenu] = useContextMenu(); |
|||
|
|||
const getFieldNames = computed((): Required<FieldNames> => { |
|||
const { fieldNames } = props; |
|||
return { |
|||
children: 'children', |
|||
title: 'title', |
|||
key: 'key', |
|||
...fieldNames, |
|||
}; |
|||
}); |
|||
|
|||
const getBindValues = computed(() => { |
|||
let propsData = { |
|||
blockNode: true, |
|||
...attrs, |
|||
...props, |
|||
expandedKeys: state.expandedKeys, |
|||
selectedKeys: state.selectedKeys, |
|||
checkedKeys: state.checkedKeys, |
|||
checkStrictly: state.checkStrictly, |
|||
fieldNames: unref(getFieldNames), |
|||
'onUpdate:expandedKeys': (v: KeyType[]) => { |
|||
state.expandedKeys = v; |
|||
emit('update:expandedKeys', v); |
|||
}, |
|||
'onUpdate:selectedKeys': (v: KeyType[], row) => { |
|||
state.selectedKeys = v; |
|||
emit('update:selectedKeys', v, row); |
|||
}, |
|||
onCheck: (v: CheckKeys, e) => { |
|||
let currentValue = toRaw(state.checkedKeys) as KeyType[]; |
|||
if (isArray(currentValue) && searchState.startSearch) { |
|||
const value = e.node.eventKey; |
|||
currentValue = difference(currentValue, getChildrenKeys(value)); |
|||
if (e.checked) { |
|||
currentValue.push(value); |
|||
} |
|||
state.checkedKeys = currentValue; |
|||
} else { |
|||
state.checkedKeys = v; |
|||
} |
|||
|
|||
const rawVal = toRaw(state.checkedKeys); |
|||
emit('update:value', rawVal); |
|||
emit('check', rawVal, e); |
|||
}, |
|||
onRightClick: handleRightClick, |
|||
}; |
|||
return omit(propsData, 'treeData', 'class'); |
|||
}); |
|||
|
|||
const getTreeData = computed((): TreeItem[] => |
|||
searchState.startSearch ? searchState.searchData : unref(treeDataRef), |
|||
); |
|||
|
|||
const getNotFound = computed((): boolean => { |
|||
return !getTreeData.value || getTreeData.value.length === 0; |
|||
}); |
|||
|
|||
const { |
|||
deleteNodeByKey, |
|||
insertNodeByKey, |
|||
insertNodesByKey, |
|||
filterByLevel, |
|||
updateNodeByKey, |
|||
getAllKeys, |
|||
getChildrenKeys, |
|||
getEnabledKeys, |
|||
getSelectedNode, |
|||
} = useTree(treeDataRef, getFieldNames); |
|||
|
|||
function getIcon(params: Recordable, icon?: string) { |
|||
if (!icon) { |
|||
if (props.renderIcon && isFunction(props.renderIcon)) { |
|||
return props.renderIcon(params); |
|||
} |
|||
} |
|||
return icon; |
|||
} |
|||
|
|||
async function handleRightClick({ event, node }: Recordable) { |
|||
const { rightMenuList: menuList = [], beforeRightClick } = props; |
|||
let contextMenuOptions: CreateContextOptions = { event, items: [] }; |
|||
|
|||
if (beforeRightClick && isFunction(beforeRightClick)) { |
|||
let result = await beforeRightClick(node, event); |
|||
if (Array.isArray(result)) { |
|||
contextMenuOptions.items = result; |
|||
} else { |
|||
Object.assign(contextMenuOptions, result); |
|||
} |
|||
} else { |
|||
contextMenuOptions.items = menuList; |
|||
} |
|||
if (!contextMenuOptions.items?.length) return; |
|||
contextMenuOptions.items = contextMenuOptions.items.filter((item) => !item.hidden); |
|||
createContextMenu(contextMenuOptions); |
|||
} |
|||
|
|||
function setExpandedKeys(keys: KeyType[]) { |
|||
state.expandedKeys = keys; |
|||
} |
|||
|
|||
function getExpandedKeys() { |
|||
return state.expandedKeys; |
|||
} |
|||
function setSelectedKeys(keys: KeyType[]) { |
|||
state.selectedKeys = keys; |
|||
} |
|||
|
|||
function getSelectedKeys() { |
|||
return state.selectedKeys; |
|||
} |
|||
|
|||
function setCheckedKeys(keys: CheckKeys) { |
|||
state.checkedKeys = keys; |
|||
} |
|||
|
|||
function getCheckedKeys() { |
|||
return state.checkedKeys; |
|||
} |
|||
|
|||
function checkAll(checkAll: boolean) { |
|||
state.checkedKeys = checkAll ? getEnabledKeys() : ([] as KeyType[]); |
|||
} |
|||
|
|||
function expandAll(expandAll: boolean) { |
|||
state.expandedKeys = expandAll ? getAllKeys() : ([] as KeyType[]); |
|||
} |
|||
|
|||
function onStrictlyChange(strictly: boolean) { |
|||
state.checkStrictly = strictly; |
|||
} |
|||
|
|||
watch( |
|||
() => props.searchValue, |
|||
(val) => { |
|||
if (val !== searchState.searchText) { |
|||
searchState.searchText = val; |
|||
} |
|||
}, |
|||
{ |
|||
immediate: true, |
|||
}, |
|||
); |
|||
|
|||
watch( |
|||
() => props.treeData, |
|||
(val) => { |
|||
if (val) { |
|||
handleSearch(searchState.searchText); |
|||
} |
|||
}, |
|||
); |
|||
|
|||
function handleSearch(searchValue: string) { |
|||
if (searchValue !== searchState.searchText) searchState.searchText = searchValue; |
|||
emit('update:searchValue', searchValue); |
|||
if (!searchValue) { |
|||
searchState.startSearch = false; |
|||
return; |
|||
} |
|||
const { filterFn, checkable, expandOnSearch, checkOnSearch, selectedOnSearch } = |
|||
unref(props); |
|||
searchState.startSearch = true; |
|||
const { title: titleField, key: keyField } = unref(getFieldNames); |
|||
|
|||
const matchedKeys: string[] = []; |
|||
searchState.searchData = filter( |
|||
unref(treeDataRef), |
|||
(node) => { |
|||
const result = filterFn |
|||
? filterFn(searchValue, node, unref(getFieldNames)) |
|||
: node[titleField]?.includes(searchValue) ?? false; |
|||
if (result) { |
|||
matchedKeys.push(node[keyField]); |
|||
} |
|||
return result; |
|||
}, |
|||
unref(getFieldNames), |
|||
); |
|||
|
|||
if (expandOnSearch) { |
|||
const expandKeys = treeToList(searchState.searchData).map((val) => { |
|||
return val[keyField]; |
|||
}); |
|||
if (expandKeys && expandKeys.length) { |
|||
setExpandedKeys(expandKeys); |
|||
} |
|||
} |
|||
|
|||
if (checkOnSearch && checkable && matchedKeys.length) { |
|||
setCheckedKeys(matchedKeys); |
|||
} |
|||
|
|||
if (selectedOnSearch && matchedKeys.length) { |
|||
setSelectedKeys(matchedKeys); |
|||
} |
|||
} |
|||
|
|||
function handleClickNode(key: string, children: TreeItem[]) { |
|||
if (!props.clickRowToExpand || !children || children.length === 0) return; |
|||
if (!state.expandedKeys.includes(key)) { |
|||
setExpandedKeys([...state.expandedKeys, key]); |
|||
} else { |
|||
const keys = [...state.expandedKeys]; |
|||
const index = keys.findIndex((item) => item === key); |
|||
if (index !== -1) { |
|||
keys.splice(index, 1); |
|||
} |
|||
setExpandedKeys(keys); |
|||
} |
|||
} |
|||
|
|||
watchEffect(() => { |
|||
treeDataRef.value = props.treeData as TreeItem[]; |
|||
}); |
|||
|
|||
onMounted(() => { |
|||
const level = parseInt(props.defaultExpandLevel); |
|||
if (level > 0) { |
|||
state.expandedKeys = filterByLevel(level); |
|||
} else if (props.defaultExpandAll) { |
|||
expandAll(true); |
|||
} |
|||
}); |
|||
|
|||
watchEffect(() => { |
|||
state.expandedKeys = props.expandedKeys; |
|||
}); |
|||
|
|||
watchEffect(() => { |
|||
state.selectedKeys = props.selectedKeys; |
|||
}); |
|||
|
|||
watchEffect(() => { |
|||
state.checkedKeys = props.checkedKeys; |
|||
}); |
|||
|
|||
watch( |
|||
() => props.value, |
|||
() => { |
|||
state.checkedKeys = toRaw(props.value || []); |
|||
}, |
|||
{ immediate: true }, |
|||
); |
|||
|
|||
watch( |
|||
() => state.checkedKeys, |
|||
() => { |
|||
const v = toRaw(state.checkedKeys); |
|||
emit('update:value', v); |
|||
emit('change', v); |
|||
}, |
|||
); |
|||
|
|||
watchEffect(() => { |
|||
state.checkStrictly = props.checkStrictly; |
|||
}); |
|||
|
|||
const instance: TreeActionType = { |
|||
setExpandedKeys, |
|||
getExpandedKeys, |
|||
setSelectedKeys, |
|||
getSelectedKeys, |
|||
setCheckedKeys, |
|||
getCheckedKeys, |
|||
insertNodeByKey, |
|||
insertNodesByKey, |
|||
deleteNodeByKey, |
|||
updateNodeByKey, |
|||
getSelectedNode, |
|||
checkAll, |
|||
expandAll, |
|||
getAllKeys, |
|||
filterByLevel: (level: number) => { |
|||
state.expandedKeys = filterByLevel(level); |
|||
}, |
|||
setSearchValue: (value: string) => { |
|||
handleSearch(value); |
|||
}, |
|||
getSearchValue: () => { |
|||
return searchState.searchText; |
|||
}, |
|||
}; |
|||
|
|||
// const { isPermission } = usePermission(); |
|||
|
|||
function renderAction(node: TreeItem) { |
|||
const { actionList } = props; |
|||
if (!actionList || actionList.length === 0) return; |
|||
return actionList.map((item, index) => { |
|||
let nodeShow = true; |
|||
if (isFunction(item.show)) { |
|||
nodeShow = item.show?.(node); |
|||
} else if (isBoolean(item.show)) { |
|||
nodeShow = item.show; |
|||
} |
|||
|
|||
if (!nodeShow) return null; |
|||
|
|||
if (!hasPermission(item.auth as string)) { |
|||
return null; |
|||
} |
|||
|
|||
return ( |
|||
<span key={index} class="action"> |
|||
{item.render(node)} |
|||
</span> |
|||
); |
|||
}); |
|||
} |
|||
|
|||
const treeData = computed(() => { |
|||
const data = cloneDeep(getTreeData.value); |
|||
eachTree(data, (item, _parent) => { |
|||
const searchText = searchState.searchText; |
|||
const { highlight } = unref(props); |
|||
const { |
|||
title: titleField, |
|||
key: keyField, |
|||
children: childrenField, |
|||
} = unref(getFieldNames); |
|||
|
|||
const icon = getIcon(item, item.icon); |
|||
const title = get(item, titleField); |
|||
|
|||
const searchIdx = searchText ? title.indexOf(searchText) : -1; |
|||
const isHighlight = |
|||
searchState.startSearch && !isEmpty(searchText) && highlight && searchIdx !== -1; |
|||
const highlightStyle = `color: ${isBoolean(highlight) ? '#f50' : highlight}`; |
|||
|
|||
const titleDom = isHighlight ? ( |
|||
<span class={unref(getBindValues)?.blockNode ? `${'content'}` : ''}> |
|||
<span>{title.substr(0, searchIdx)}</span> |
|||
<span style={highlightStyle}>{searchText}</span> |
|||
<span>{title.substr(searchIdx + (searchText as string).length)}</span> |
|||
</span> |
|||
) : ( |
|||
title |
|||
); |
|||
|
|||
item[titleField] = ( |
|||
<span |
|||
class={`tree-title pl-2`} |
|||
onClick={handleClickNode.bind(null, item[keyField], item[childrenField])} |
|||
> |
|||
{item?.slots?.title ? ( |
|||
getSlot(slots, 'title', item) |
|||
) : ( |
|||
<> |
|||
{icon && <TreeIcon icon={icon} />} |
|||
{item?.slots?.titleBefore && getSlot(slots, item?.slots?.titleBefore, item)} |
|||
{titleDom} |
|||
{item?.slots?.titleAfter && getSlot(slots, item?.slots?.titleAfter, item)} |
|||
<span class="tree-actions">{renderAction(item)}</span> |
|||
</> |
|||
)} |
|||
</span> |
|||
); |
|||
delete item?.slots; |
|||
return item; |
|||
}); |
|||
return data; |
|||
}); |
|||
|
|||
expose(instance); |
|||
|
|||
return () => { |
|||
const { title, helpMessage, toolbar, toolbarStrictly, search, checkable } = props; |
|||
const showTitle = title || toolbar || search || slots.headerTitle; |
|||
const scrollStyle: CSSProperties = { height: 'calc(100% - 38px)' }; |
|||
return ( |
|||
<div class={['basic-tree', 'h-full', attrs.class]}> |
|||
{showTitle && ( |
|||
<TreeHeader |
|||
checkable={checkable} |
|||
checkAll={checkAll} |
|||
expandAll={expandAll} |
|||
title={title} |
|||
search={search} |
|||
toolbar={toolbar} |
|||
toolbarStrictly={toolbarStrictly} |
|||
helpMessage={helpMessage} |
|||
onStrictlyChange={onStrictlyChange} |
|||
onSearch={handleSearch} |
|||
searchText={searchState.searchText} |
|||
> |
|||
{extendSlots(slots)} |
|||
</TreeHeader> |
|||
)} |
|||
|
|||
<Spin |
|||
wrapperClassName={unref(props.treeWrapperClassName)} |
|||
spinning={unref(props.loading)} |
|||
tip="加载中..." |
|||
> |
|||
{/* <ScrollContainer style={scrollStyle} v-show={!unref(getNotFound)}> */} |
|||
<Tree {...unref(getBindValues)} showIcon={false} treeData={treeData.value} /> |
|||
{/* </ScrollContainer> */} |
|||
<Empty |
|||
v-show={unref(getNotFound)} |
|||
image={Empty.PRESENTED_IMAGE_SIMPLE} |
|||
class="!mt-4" |
|||
/> |
|||
</Spin> |
|||
</div> |
|||
); |
|||
}; |
|||
}, |
|||
}); |
|||
</script> |
|||
@ -0,0 +1,14 @@ |
|||
import type { VNode, FunctionalComponent } from 'vue'; |
|||
|
|||
import { h } from 'vue'; |
|||
import { isString } from '@vue/shared'; |
|||
import { IconFont } from '@/components/basic/iconfont'; |
|||
|
|||
|
|||
export const TreeIcon: FunctionalComponent = ({ icon }: { icon: VNode | string }) => { |
|||
if (!icon) return null; |
|||
if (isString(icon)) { |
|||
return h(IconFont, { type:icon, class: 'mr-1' }); |
|||
} |
|||
return IconFont; |
|||
}; |
|||
@ -0,0 +1,172 @@ |
|||
<template> |
|||
<div class="flex px-2 py-1.5 items-center"> |
|||
<slot name="headerTitle" v-if="slots.headerTitle"></slot> |
|||
{{ title }} |
|||
<!-- <BasicTitle :helpMessage="helpMessage" v-if="!slots.headerTitle && title"> |
|||
{{ title }} |
|||
</BasicTitle> --> |
|||
<div |
|||
class="flex items-center flex-1 cursor-pointer justify-self-stretch" |
|||
v-if="search || toolbar" |
|||
> |
|||
<div :class="getInputSearchCls" v-if="search"> |
|||
<InputSearch placeholder="请输入" size="small" allowClear v-model:value="searchValue" /> |
|||
</div> |
|||
<Dropdown @click.prevent v-if="toolbar"> |
|||
<!-- <Icon icon="ion:ellipsis-vertical" /> --> |
|||
<more-outlined /> |
|||
<template #overlay> |
|||
<Menu @click="handleMenuClick"> |
|||
<template v-for="item in toolbarList" :key="item.value"> |
|||
<MenuItem v-bind="{ key: item.value }"> |
|||
{{ item.label }} |
|||
</MenuItem> |
|||
<MenuDivider v-if="item.divider" /> |
|||
</template> |
|||
</Menu> |
|||
</template> |
|||
</Dropdown> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
<script lang="ts" setup> |
|||
import { computed, ref, watch, useSlots } from 'vue'; |
|||
import { Dropdown, Menu, MenuItem, MenuDivider, InputSearch } from 'ant-design-vue'; |
|||
import { MoreOutlined } from '@ant-design/icons-vue'; |
|||
import { useDebounceFn } from '@vueuse/core'; |
|||
import { ToolbarEnum } from '../types/tree'; |
|||
|
|||
const searchValue = ref(''); |
|||
|
|||
const props = defineProps({ |
|||
helpMessage: { |
|||
type: [String, Array] as PropType<string | string[]>, |
|||
default: '', |
|||
}, |
|||
title: { |
|||
type: String, |
|||
default: '', |
|||
}, |
|||
toolbar: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
checkable: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
search: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
searchText: { |
|||
type: String, |
|||
default: '', |
|||
}, |
|||
checkAll: { |
|||
type: Function, |
|||
default: undefined, |
|||
}, |
|||
expandAll: { |
|||
type: Function, |
|||
default: undefined, |
|||
}, |
|||
// 工具栏是否显示 层级关联 |
|||
toolbarStrictly: { |
|||
type: Boolean, |
|||
default: true, |
|||
}, |
|||
} as const); |
|||
const emit = defineEmits(['strictly-change', 'search']); |
|||
|
|||
const slots = useSlots(); |
|||
|
|||
const getInputSearchCls = computed(() => { |
|||
const titleExists = slots.headerTitle || props.title; |
|||
return [ |
|||
'mr-1', |
|||
'w-full', |
|||
{ |
|||
['ml-5']: titleExists, |
|||
}, |
|||
]; |
|||
}); |
|||
|
|||
const toolbarList = computed(() => { |
|||
const { checkable, toolbarStrictly } = props; |
|||
const defaultToolbarList = [ |
|||
{ label: '展开全部', value: ToolbarEnum.EXPAND_ALL }, |
|||
{ |
|||
label: '折叠全部', |
|||
value: ToolbarEnum.UN_EXPAND_ALL, |
|||
divider: checkable, |
|||
}, |
|||
]; |
|||
|
|||
const strictlyList = toolbarStrictly |
|||
? [ |
|||
{ label: '层级关联', value: ToolbarEnum.CHECK_STRICTLY }, |
|||
{ label: '层级独立', value: ToolbarEnum.CHECK_UN_STRICTLY }, |
|||
] |
|||
: []; |
|||
|
|||
return checkable |
|||
? [ |
|||
{ label: '选择全部', value: ToolbarEnum.SELECT_ALL }, |
|||
{ |
|||
label: '取消选择', |
|||
value: ToolbarEnum.UN_SELECT_ALL, |
|||
divider: checkable, |
|||
}, |
|||
...defaultToolbarList, |
|||
...strictlyList, |
|||
] |
|||
: defaultToolbarList; |
|||
}); |
|||
|
|||
function handleMenuClick(e: { key: ToolbarEnum }) { |
|||
const { key } = e; |
|||
switch (key) { |
|||
case ToolbarEnum.SELECT_ALL: |
|||
props.checkAll?.(true); |
|||
break; |
|||
case ToolbarEnum.UN_SELECT_ALL: |
|||
props.checkAll?.(false); |
|||
break; |
|||
case ToolbarEnum.EXPAND_ALL: |
|||
props.expandAll?.(true); |
|||
break; |
|||
case ToolbarEnum.UN_EXPAND_ALL: |
|||
props.expandAll?.(false); |
|||
break; |
|||
case ToolbarEnum.CHECK_STRICTLY: |
|||
emit('strictly-change', false); |
|||
break; |
|||
case ToolbarEnum.CHECK_UN_STRICTLY: |
|||
emit('strictly-change', true); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
function emitChange(value?: string): void { |
|||
emit('search', value); |
|||
} |
|||
|
|||
const debounceEmitChange = useDebounceFn(emitChange, 200); |
|||
|
|||
watch( |
|||
() => searchValue.value, |
|||
(v) => { |
|||
debounceEmitChange(v); |
|||
}, |
|||
); |
|||
|
|||
watch( |
|||
() => props.searchText, |
|||
(v) => { |
|||
if (v !== searchValue.value) { |
|||
searchValue.value = v; |
|||
} |
|||
}, |
|||
); |
|||
</script> |
|||
@ -0,0 +1,207 @@ |
|||
import type { InsertNodeParams, KeyType, FieldNames, TreeItem } from '../types/tree'; |
|||
import type { Ref, ComputedRef } from 'vue'; |
|||
import type { TreeDataItem } from 'ant-design-vue/es/tree/Tree'; |
|||
|
|||
import { cloneDeep } from 'lodash-es'; |
|||
import { unref } from 'vue'; |
|||
import { forEach } from '@/utils/helper/treeHelper'; |
|||
|
|||
export function useTree(treeDataRef: Ref<TreeDataItem[]>, getFieldNames: ComputedRef<FieldNames>) { |
|||
function getAllKeys(list?: TreeDataItem[]) { |
|||
const keys: string[] = []; |
|||
const treeData = list || unref(treeDataRef); |
|||
const { key: keyField, children: childrenField } = unref(getFieldNames); |
|||
if (!childrenField || !keyField) return keys; |
|||
|
|||
for (let index = 0; index < treeData.length; index++) { |
|||
const node = treeData[index]; |
|||
keys.push(node[keyField]!); |
|||
const children = node[childrenField]; |
|||
if (children && children.length) { |
|||
keys.push(...(getAllKeys(children) as string[])); |
|||
} |
|||
} |
|||
return keys as KeyType[]; |
|||
} |
|||
|
|||
// get keys that can be checked and selected
|
|||
function getEnabledKeys(list?: TreeDataItem[]) { |
|||
const keys: string[] = []; |
|||
const treeData = list || unref(treeDataRef); |
|||
const { key: keyField, children: childrenField } = unref(getFieldNames); |
|||
if (!childrenField || !keyField) return keys; |
|||
|
|||
for (let index = 0; index < treeData.length; index++) { |
|||
const node = treeData[index]; |
|||
node.disabled !== true && node.selectable !== false && keys.push(node[keyField]!); |
|||
const children = node[childrenField]; |
|||
if (children && children.length) { |
|||
keys.push(...(getEnabledKeys(children) as string[])); |
|||
} |
|||
} |
|||
return keys as KeyType[]; |
|||
} |
|||
|
|||
function getChildrenKeys(nodeKey: string | number, list?: TreeDataItem[]) { |
|||
const keys: KeyType[] = []; |
|||
const treeData = list || unref(treeDataRef); |
|||
const { key: keyField, children: childrenField } = unref(getFieldNames); |
|||
if (!childrenField || !keyField) return keys; |
|||
for (let index = 0; index < treeData.length; index++) { |
|||
const node = treeData[index]; |
|||
const children = node[childrenField]; |
|||
if (nodeKey === node[keyField]) { |
|||
keys.push(node[keyField]!); |
|||
if (children && children.length) { |
|||
keys.push(...(getAllKeys(children) as string[])); |
|||
} |
|||
} else { |
|||
if (children && children.length) { |
|||
keys.push(...getChildrenKeys(nodeKey, children)); |
|||
} |
|||
} |
|||
} |
|||
return keys as KeyType[]; |
|||
} |
|||
|
|||
// Update node
|
|||
function updateNodeByKey(key: string, node: TreeDataItem, list?: TreeDataItem[]) { |
|||
if (!key) return; |
|||
const treeData = list || unref(treeDataRef); |
|||
const { key: keyField, children: childrenField } = unref(getFieldNames); |
|||
|
|||
if (!childrenField || !keyField) return; |
|||
|
|||
for (let index = 0; index < treeData.length; index++) { |
|||
const element: any = treeData[index]; |
|||
const children = element[childrenField]; |
|||
|
|||
if (element[keyField] === key) { |
|||
treeData[index] = { ...treeData[index], ...node }; |
|||
break; |
|||
} else if (children && children.length) { |
|||
updateNodeByKey(key, node, element[childrenField]); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Expand the specified level
|
|||
function filterByLevel(level = 1, list?: TreeDataItem[], currentLevel = 1) { |
|||
if (!level) { |
|||
return []; |
|||
} |
|||
const res: (string | number)[] = []; |
|||
const data = list || unref(treeDataRef) || []; |
|||
for (let index = 0; index < data.length; index++) { |
|||
const item = data[index]; |
|||
|
|||
const { key: keyField, children: childrenField } = unref(getFieldNames); |
|||
const key = keyField ? item[keyField] : ''; |
|||
const children = childrenField ? item[childrenField] : []; |
|||
res.push(key); |
|||
if (children && children.length && currentLevel < level) { |
|||
currentLevel += 1; |
|||
res.push(...filterByLevel(level, children, currentLevel)); |
|||
} |
|||
} |
|||
return res as string[] | number[]; |
|||
} |
|||
|
|||
/** |
|||
* 添加节点 |
|||
*/ |
|||
function insertNodeByKey({ parentKey = null, node, push = 'push' }: InsertNodeParams) { |
|||
const treeData: any = cloneDeep(unref(treeDataRef)); |
|||
if (!parentKey) { |
|||
treeData[push](node); |
|||
treeDataRef.value = treeData; |
|||
return; |
|||
} |
|||
const { key: keyField, children: childrenField } = unref(getFieldNames); |
|||
if (!childrenField || !keyField) return; |
|||
|
|||
forEach(treeData, (treeItem) => { |
|||
if (treeItem[keyField] === parentKey) { |
|||
treeItem[childrenField] = treeItem[childrenField] || []; |
|||
treeItem[childrenField][push](node); |
|||
return true; |
|||
} |
|||
}); |
|||
treeDataRef.value = treeData; |
|||
} |
|||
/** |
|||
* 批量添加节点 |
|||
*/ |
|||
function insertNodesByKey({ parentKey = null, list, push = 'push' }: InsertNodeParams) { |
|||
const treeData: any = cloneDeep(unref(treeDataRef)); |
|||
if (!list || list.length < 1) { |
|||
return; |
|||
} |
|||
if (!parentKey) { |
|||
for (let i = 0; i < list.length; i++) { |
|||
treeData[push](list[i]); |
|||
} |
|||
} else { |
|||
const { key: keyField, children: childrenField } = unref(getFieldNames); |
|||
if (!childrenField || !keyField) return; |
|||
|
|||
forEach(treeData, (treeItem) => { |
|||
if (treeItem[keyField] === parentKey) { |
|||
treeItem[childrenField] = treeItem[childrenField] || []; |
|||
for (let i = 0; i < list.length; i++) { |
|||
treeItem[childrenField][push](list[i]); |
|||
} |
|||
treeDataRef.value = treeData; |
|||
return true; |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
// Delete node
|
|||
function deleteNodeByKey(key: string, list?: TreeDataItem[]) { |
|||
if (!key) return; |
|||
const treeData = list || unref(treeDataRef); |
|||
const { key: keyField, children: childrenField } = unref(getFieldNames); |
|||
if (!childrenField || !keyField) return; |
|||
|
|||
for (let index = 0; index < treeData.length; index++) { |
|||
const element: any = treeData[index]; |
|||
const children = element[childrenField]; |
|||
|
|||
if (element[keyField] === key) { |
|||
treeData.splice(index, 1); |
|||
break; |
|||
} else if (children && children.length) { |
|||
deleteNodeByKey(key, element[childrenField]); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Get selected node
|
|||
function getSelectedNode(key: KeyType, list?: TreeItem[], selectedNode?: TreeItem | null) { |
|||
if (!key && key !== 0) return null; |
|||
const treeData = list || unref(treeDataRef); |
|||
treeData.forEach((item) => { |
|||
if (selectedNode?.key || selectedNode?.key === 0) return selectedNode; |
|||
if (item.key === key) { |
|||
selectedNode = item; |
|||
return; |
|||
} |
|||
if (item.children && item.children.length) { |
|||
selectedNode = getSelectedNode(key, item.children, selectedNode); |
|||
} |
|||
}); |
|||
return selectedNode || null; |
|||
} |
|||
return { |
|||
deleteNodeByKey, |
|||
insertNodeByKey, |
|||
insertNodesByKey, |
|||
filterByLevel, |
|||
updateNodeByKey, |
|||
getAllKeys, |
|||
getChildrenKeys, |
|||
getEnabledKeys, |
|||
getSelectedNode, |
|||
}; |
|||
} |
|||
@ -0,0 +1,204 @@ |
|||
import type { ExtractPropTypes } from 'vue'; |
|||
import type { TreeDataItem } from 'ant-design-vue/es/tree/Tree'; |
|||
|
|||
import { buildProps } from '@/utils/props'; |
|||
import { RoleEnum } from '@/enums/roleEnum'; |
|||
// import { PermModeEnum, RoleEnum } from '/@/enums/roleEnum';
|
|||
|
|||
export enum ToolbarEnum { |
|||
SELECT_ALL, |
|||
UN_SELECT_ALL, |
|||
EXPAND_ALL, |
|||
UN_EXPAND_ALL, |
|||
CHECK_STRICTLY, |
|||
CHECK_UN_STRICTLY, |
|||
} |
|||
|
|||
export const treeEmits = [ |
|||
'update:expandedKeys', |
|||
'update:selectedKeys', |
|||
'update:value', |
|||
'change', |
|||
'check', |
|||
'update:searchValue', |
|||
]; |
|||
|
|||
export interface TreeState { |
|||
expandedKeys: KeyType[]; |
|||
selectedKeys: KeyType[]; |
|||
checkedKeys: CheckKeys; |
|||
checkStrictly: boolean; |
|||
} |
|||
|
|||
export interface FieldNames { |
|||
children?: string; |
|||
title?: string; |
|||
key?: string; |
|||
} |
|||
|
|||
export type KeyType = string | number; |
|||
|
|||
export type CheckKeys = |
|||
| KeyType[] |
|||
| { checked: string[] | number[]; halfChecked: string[] | number[] }; |
|||
|
|||
export const treeProps = buildProps({ |
|||
value: { |
|||
type: [Object, Array] as PropType<KeyType[] | CheckKeys>, |
|||
}, |
|||
|
|||
renderIcon: { |
|||
type: Function as PropType<(params: Recordable) => string>, |
|||
}, |
|||
|
|||
helpMessage: { |
|||
type: [String, Array] as PropType<string | string[]>, |
|||
default: '', |
|||
}, |
|||
|
|||
title: { |
|||
type: String, |
|||
default: '', |
|||
}, |
|||
toolbar: Boolean, |
|||
search: Boolean, |
|||
searchValue: { |
|||
type: String, |
|||
default: '', |
|||
}, |
|||
checkStrictly: Boolean, |
|||
clickRowToExpand: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
checkable: Boolean, |
|||
defaultExpandLevel: { |
|||
type: [String, Number] as PropType<string | number>, |
|||
default: '', |
|||
}, |
|||
defaultExpandAll: Boolean, |
|||
// 工具栏是否显示 层级关联
|
|||
toolbarStrictly: { |
|||
type: Boolean, |
|||
default: true, |
|||
}, |
|||
fieldNames: { |
|||
type: Object as PropType<FieldNames>, |
|||
}, |
|||
|
|||
treeData: { |
|||
type: Array as PropType<TreeDataItem[]>, |
|||
}, |
|||
|
|||
actionList: { |
|||
type: Array as PropType<TreeActionItem[]>, |
|||
default: () => [], |
|||
}, |
|||
|
|||
expandedKeys: { |
|||
type: Array as PropType<KeyType[]>, |
|||
default: () => [], |
|||
}, |
|||
|
|||
selectedKeys: { |
|||
type: Array as PropType<KeyType[]>, |
|||
default: () => [], |
|||
}, |
|||
|
|||
checkedKeys: { |
|||
type: Array as PropType<CheckKeys>, |
|||
default: () => [], |
|||
}, |
|||
|
|||
beforeRightClick: { |
|||
type: Function as PropType<(...arg: any) => ContextMenuItem[] | ContextMenuOptions>, |
|||
default: undefined, |
|||
}, |
|||
|
|||
rightMenuList: { |
|||
type: Array as PropType<ContextMenuItem[]>, |
|||
}, |
|||
// 自定义数据过滤判断方法(注: 不是整个过滤方法,而是内置过滤的判断方法,用于增强原本仅能通过title进行过滤的方式)
|
|||
filterFn: { |
|||
type: Function as PropType< |
|||
(searchValue: any, node: TreeItem, fieldNames: FieldNames) => boolean |
|||
>, |
|||
default: undefined, |
|||
}, |
|||
// 高亮搜索值,仅高亮具体匹配值(通过title)值为true时使用默认色值,值为#xxx时使用此值替代且高亮开启
|
|||
highlight: { |
|||
type: [Boolean, String] as PropType<Boolean | String>, |
|||
default: false, |
|||
}, |
|||
// 搜索完成时自动展开结果
|
|||
expandOnSearch: Boolean, |
|||
// 搜索完成自动选中所有结果,当且仅当 checkable===true 时生效
|
|||
checkOnSearch: Boolean, |
|||
// 搜索完成自动select所有结果
|
|||
selectedOnSearch: Boolean, |
|||
loading: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
treeWrapperClassName: String, |
|||
}); |
|||
|
|||
export type TreeProps = ExtractPropTypes<typeof treeProps>; |
|||
|
|||
export interface ContextMenuItem { |
|||
label: string; |
|||
icon?: string; |
|||
hidden?: boolean; |
|||
disabled?: boolean; |
|||
handler?: Fn; |
|||
divider?: boolean; |
|||
children?: ContextMenuItem[]; |
|||
auth?: string | string[] | RoleEnum | RoleEnum[]; |
|||
} |
|||
|
|||
export interface ContextMenuOptions { |
|||
icon?: string; |
|||
styles?: any; |
|||
items?: ContextMenuItem[]; |
|||
} |
|||
|
|||
export interface TreeItem extends TreeDataItem { |
|||
icon?: any; |
|||
} |
|||
|
|||
export interface TreeActionItem { |
|||
render: (record: Recordable) => any; |
|||
show?: boolean | ((record: Recordable) => boolean); |
|||
auth?: string | string[] | RoleEnum | RoleEnum[]; |
|||
// authMode?: PermModeEnum;
|
|||
} |
|||
|
|||
export interface InsertNodeParams { |
|||
parentKey: string | null; |
|||
node: TreeDataItem; |
|||
list?: TreeDataItem[]; |
|||
push?: 'push' | 'unshift'; |
|||
} |
|||
|
|||
export interface TreeActionType { |
|||
checkAll: (checkAll: boolean) => void; |
|||
expandAll: (expandAll: boolean) => void; |
|||
setExpandedKeys: (keys: KeyType[]) => void; |
|||
getExpandedKeys: () => KeyType[]; |
|||
setSelectedKeys: (keys: KeyType[]) => void; |
|||
getSelectedKeys: () => KeyType[]; |
|||
setCheckedKeys: (keys: CheckKeys) => void; |
|||
getCheckedKeys: () => CheckKeys; |
|||
filterByLevel: (level: number) => void; |
|||
insertNodeByKey: (opt: InsertNodeParams) => void; |
|||
insertNodesByKey: (opt: InsertNodeParams) => void; |
|||
deleteNodeByKey: (key: string) => void; |
|||
updateNodeByKey: (key: string, node: Omit<TreeDataItem, 'key'>) => void; |
|||
setSearchValue: (value: string) => void; |
|||
getSearchValue: () => string; |
|||
getSelectedNode: ( |
|||
key: KeyType, |
|||
treeList?: TreeItem[], |
|||
selectNode?: TreeItem | null, |
|||
) => TreeItem | null; |
|||
} |
|||
@ -0,0 +1,46 @@ |
|||
.tree { |
|||
background-color: #fff; |
|||
.ant-tree-node-content-wrapper { |
|||
position: relative; |
|||
|
|||
.ant-tree-title { |
|||
position: absolute; |
|||
left: 0; |
|||
width: 100%; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
} |
|||
} |
|||
|
|||
&-title { |
|||
position: relative; |
|||
display: flex; |
|||
align-items: center; |
|||
width: 100%; |
|||
padding-right: 10px; |
|||
&:hover { |
|||
.action { |
|||
visibility: visible; |
|||
} |
|||
} |
|||
} |
|||
|
|||
&__content { |
|||
overflow: hidden; |
|||
} |
|||
|
|||
&-actions { |
|||
position: absolute; |
|||
right: 10px; |
|||
display: flex; |
|||
.action { |
|||
margin-left: 4px; |
|||
visibility: hidden; |
|||
} |
|||
} |
|||
|
|||
&-header { |
|||
border-bottom: 1px solid @border-color-base; |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
import './index.less'; |
|||
@ -0,0 +1 @@ |
|||
export { default as DraggableModal } from './index.vue'; |
|||
@ -0,0 +1,354 @@ |
|||
<template> |
|||
<teleport :to="getContainer()"> |
|||
<div ref="modalWrapRef" class="draggable-modal" :class="{ fullscreen: fullscreenModel }"> |
|||
<Modal |
|||
v-bind="omit(props, ['visible', 'onCancel', 'onOk', 'onUpdate:visible'])" |
|||
v-model:visible="visibleModel" |
|||
:mask-closable="false" |
|||
:get-container="() => modalWrapRef" |
|||
:width="innerWidth || width" |
|||
@ok="emit('ok')" |
|||
@cancel="emit('cancel')" |
|||
> |
|||
<template #title> |
|||
<slot name="title">{{ $attrs.title || '标题' }}</slot> |
|||
</template> |
|||
<template #closeIcon> |
|||
<slot name="closeIcon"> |
|||
<Space class="ant-modal-operate" @click.stop> |
|||
<fullscreen-outlined v-if="!fullscreenModel" @click="fullscreenModel = true" /> |
|||
<fullscreen-exit-outlined v-else @click="restore" /> |
|||
<close-outlined @click="closeModal" /> |
|||
</Space> |
|||
</slot> |
|||
</template> |
|||
<slot> |
|||
① 窗口可以拖动;<br /> |
|||
② 窗口可以通过八个方向改变大小;<br /> |
|||
③ 窗口可以最小化、最大化、还原、关闭;<br /> |
|||
④ 限制窗口最小宽度/高度。 |
|||
</slot> |
|||
<template v-if="$slots.footer" #footer> |
|||
<slot name="footer"></slot> |
|||
</template> |
|||
</Modal> |
|||
</div> |
|||
</teleport> |
|||
</template> |
|||
|
|||
<script lang="ts" setup> |
|||
import { ref, watch, nextTick } from 'vue'; |
|||
import { useRoute } from 'vue-router'; |
|||
import { modalProps } from 'ant-design-vue/es/modal/Modal'; |
|||
import { CloseOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons-vue'; |
|||
import { useVModel } from '@vueuse/core'; |
|||
import { throttle, omit } from 'lodash-es'; |
|||
import { Modal, Space } from 'ant-design-vue'; |
|||
|
|||
const props = defineProps({ |
|||
...modalProps(), |
|||
fullscreen: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
getContainer: { |
|||
type: Function, |
|||
default: () => document.body, |
|||
}, |
|||
}); |
|||
|
|||
const emit = defineEmits(['update:visible', 'update:fullscreen', 'ok', 'cancel']); |
|||
|
|||
const route = useRoute(); |
|||
const visibleModel = useVModel(props, 'visible'); |
|||
const fullscreenModel = ref(props.fullscreen); |
|||
const innerWidth = ref(''); |
|||
|
|||
const cursorStyle = { |
|||
top: 'n-resize', |
|||
left: 'w-resize', |
|||
right: 'e-resize', |
|||
bottom: 's-resize', |
|||
topLeft: 'nw-resize', |
|||
topright: 'ne-resize', |
|||
bottomLeft: 'sw-resize', |
|||
bottomRight: 'se-resize', |
|||
auto: 'auto', |
|||
} as const; |
|||
|
|||
// 是否已经初始化过了 |
|||
let inited = false; |
|||
const modalWrapRef = ref<HTMLDivElement>(); |
|||
|
|||
const closeModal = () => { |
|||
visibleModel.value = false; |
|||
emit('cancel'); |
|||
}; |
|||
|
|||
// 居中弹窗 |
|||
const centerModal = async () => { |
|||
await nextTick(); |
|||
const modalEl = modalWrapRef.value?.querySelector<HTMLDivElement>('.ant-modal'); |
|||
|
|||
if (modalEl && modalEl.getBoundingClientRect().left < 1) { |
|||
modalEl.style.left = `${(document.documentElement.clientWidth - modalEl.offsetWidth) / 2}px`; |
|||
} |
|||
}; |
|||
|
|||
const restore = async () => { |
|||
fullscreenModel.value = false; |
|||
centerModal(); |
|||
}; |
|||
|
|||
const registerDragTitle = (dragEl: HTMLDivElement, handleEl: HTMLDivElement) => { |
|||
handleEl.style.cursor = 'move'; |
|||
handleEl.onmousedown = throttle((e: MouseEvent) => { |
|||
if (fullscreenModel.value) return; |
|||
document.body.style.userSelect = 'none'; |
|||
const disX = e.clientX - dragEl.getBoundingClientRect().left; |
|||
const disY = e.clientY - dragEl.getBoundingClientRect().top; |
|||
const mousemove = (event: MouseEvent) => { |
|||
if (fullscreenModel.value) return; |
|||
let iL = event.clientX - disX; |
|||
let iT = event.clientY - disY; |
|||
const maxL = document.documentElement.clientWidth - dragEl.offsetWidth; |
|||
const maxT = document.documentElement.clientHeight - dragEl.offsetHeight; |
|||
|
|||
iL <= 0 && (iL = 0); |
|||
iT <= 0 && (iT = 0); |
|||
iL >= maxL && (iL = maxL); |
|||
iT >= maxT && (iT = maxT); |
|||
|
|||
dragEl.style.left = `${iL}px`; |
|||
dragEl.style.top = `${iT}px`; |
|||
}; |
|||
const mouseup = () => { |
|||
document.removeEventListener('mousemove', mousemove); |
|||
document.removeEventListener('mouseup', mouseup); |
|||
document.body.style.userSelect = 'auto'; |
|||
}; |
|||
|
|||
document.addEventListener('mousemove', mousemove); |
|||
document.addEventListener('mouseup', mouseup); |
|||
}, 20); |
|||
}; |
|||
|
|||
const initDrag = async () => { |
|||
await nextTick(); |
|||
const modalWrapRefEl = modalWrapRef.value!; |
|||
const modalWrapEl = modalWrapRefEl.querySelector<HTMLDivElement>('.ant-modal-wrap'); |
|||
const modalEl = modalWrapRefEl.querySelector<HTMLDivElement>('.ant-modal'); |
|||
if (modalWrapEl && modalEl) { |
|||
centerModal(); |
|||
const headerEl = modalEl.querySelector<HTMLDivElement>('.ant-modal-header'); |
|||
headerEl && registerDragTitle(modalEl, headerEl); |
|||
|
|||
modalWrapEl.onmousemove = throttle((event: MouseEvent) => { |
|||
if (fullscreenModel.value) return; |
|||
const left = event.clientX - modalEl.offsetLeft; |
|||
const top = event.clientY - modalEl.offsetTop; |
|||
const right = event.clientX - modalEl.offsetWidth - modalEl.offsetLeft; |
|||
const bottom = event.clientY - modalEl.offsetHeight - modalEl.offsetTop; |
|||
const isLeft = left <= 0 && left > -8; |
|||
const isTop = top < 5 && top > -8; |
|||
const isRight = right >= 0 && right < 8; |
|||
const isBottom = bottom > -5 && bottom < 8; |
|||
// 向左 |
|||
if (isLeft && top > 5 && bottom < -5) { |
|||
modalWrapEl.style.cursor = cursorStyle.left; |
|||
// 向上 |
|||
} else if (isTop && left > 5 && right < -5) { |
|||
modalWrapEl.style.cursor = cursorStyle.top; |
|||
// 向右 |
|||
} else if (isRight && top > 5 && bottom < -5) { |
|||
modalWrapEl.style.cursor = cursorStyle.right; |
|||
// 向下 |
|||
} else if (isBottom && left > 5 && right < -5) { |
|||
modalWrapEl.style.cursor = cursorStyle.bottom; |
|||
// 左上角 |
|||
} else if (left > -8 && left <= 5 && top <= 5 && top > -8) { |
|||
modalWrapEl.style.cursor = cursorStyle.topLeft; |
|||
// 左下角 |
|||
} else if (left > -8 && left <= 5 && bottom <= 5 && bottom > -8) { |
|||
modalWrapEl.style.cursor = cursorStyle.bottomLeft; |
|||
// 右上角 |
|||
} else if (right < 8 && right >= -5 && top <= 5 && top > -8) { |
|||
modalWrapEl.style.cursor = cursorStyle.topright; |
|||
// 右下角 |
|||
} else if (right < 8 && right >= -5 && bottom <= 5 && bottom > -8) { |
|||
modalWrapEl.style.cursor = cursorStyle.bottomRight; |
|||
} else { |
|||
modalWrapEl.style.cursor = cursorStyle.auto; |
|||
} |
|||
}, 20); |
|||
modalWrapEl.onmousedown = (e: MouseEvent) => { |
|||
if (fullscreenModel.value) return; |
|||
const { |
|||
top: iParentTop, |
|||
bottom: iParentBottom, |
|||
left: iParentLeft, |
|||
right: iParentRight, |
|||
} = modalEl.getBoundingClientRect(); |
|||
const disX = e.clientX - iParentLeft; |
|||
const disY = e.clientY - iParentTop; |
|||
const iParentWidth = modalEl.offsetWidth; |
|||
const iParentHeight = modalEl.offsetHeight; |
|||
|
|||
const cursor = modalWrapEl.style.cursor; |
|||
|
|||
const mousemove = throttle((event: MouseEvent) => { |
|||
if (fullscreenModel.value) return; |
|||
if (cursor !== cursorStyle.auto) { |
|||
document.body.style.userSelect = 'none'; |
|||
} |
|||
const mLeft = `${Math.max(0, event.clientX - disX)}px`; |
|||
const mTop = `${Math.max(0, event.clientY - disY)}px`; |
|||
const mLeftWidth = `${Math.min( |
|||
iParentRight, |
|||
iParentWidth + iParentLeft - event.clientX, |
|||
)}px`; |
|||
const mRightWidth = `${Math.min( |
|||
window.innerWidth - iParentLeft, |
|||
event.clientX - iParentLeft, |
|||
)}px`; |
|||
const mTopHeight = `${Math.min( |
|||
iParentBottom, |
|||
iParentHeight + iParentTop - event.clientY, |
|||
)}px`; |
|||
const mBottomHeight = `${Math.min( |
|||
window.innerHeight - iParentTop, |
|||
event.clientY - iParentTop, |
|||
)}px`; |
|||
|
|||
// 向左边拖拽 |
|||
if (cursor === cursorStyle.left) { |
|||
modalEl.style.left = mLeft; |
|||
modalEl.style.width = mLeftWidth; |
|||
// 向上边拖拽 |
|||
} else if (cursor === cursorStyle.top) { |
|||
modalEl.style.top = mTop; |
|||
modalEl.style.height = mTopHeight; |
|||
// 向右边拖拽 |
|||
} else if (cursor === cursorStyle.right) { |
|||
modalEl.style.width = mRightWidth; |
|||
// 向下拖拽 |
|||
} else if (cursor === cursorStyle.bottom) { |
|||
modalEl.style.height = mBottomHeight; |
|||
// 左上角拖拽 |
|||
} else if (cursor === cursorStyle.topLeft) { |
|||
modalEl.style.left = mLeft; |
|||
modalEl.style.top = mTop; |
|||
modalEl.style.height = mTopHeight; |
|||
modalEl.style.width = mLeftWidth; |
|||
// 右上角拖拽 |
|||
} else if (cursor === cursorStyle.topright) { |
|||
modalEl.style.top = mTop; |
|||
modalEl.style.width = mRightWidth; |
|||
modalEl.style.height = mTopHeight; |
|||
// 左下角拖拽 |
|||
} else if (cursor === cursorStyle.bottomLeft) { |
|||
modalEl.style.left = mLeft; |
|||
modalEl.style.width = mLeftWidth; |
|||
modalEl.style.height = mBottomHeight; |
|||
// 右下角拖拽 |
|||
} else if (cursor === cursorStyle.bottomRight) { |
|||
modalEl.style.width = mRightWidth; |
|||
modalEl.style.height = mBottomHeight; |
|||
} |
|||
innerWidth.value = modalEl.style.width; |
|||
}, 20); |
|||
|
|||
const mouseup = () => { |
|||
document.removeEventListener('mousemove', mousemove); |
|||
document.removeEventListener('mouseup', mouseup); |
|||
document.body.style.userSelect = 'auto'; |
|||
modalWrapEl.style.cursor = cursorStyle.auto; |
|||
}; |
|||
|
|||
document.addEventListener('mousemove', mousemove); |
|||
document.addEventListener('mouseup', mouseup); |
|||
}; |
|||
} |
|||
inited = true; |
|||
}; |
|||
|
|||
watch(visibleModel, async (val) => { |
|||
if ((val && Object.is(inited, false)) || props.destroyOnClose) { |
|||
initDrag(); |
|||
} |
|||
}); |
|||
|
|||
watch(() => route.fullPath, closeModal); |
|||
</script> |
|||
|
|||
<style lang="less"> |
|||
.draggable-modal { |
|||
&.fullscreen { |
|||
.ant-modal { |
|||
top: 0 !important; |
|||
right: 0 !important; |
|||
bottom: 0 !important; |
|||
left: 0 !important; |
|||
width: 100% !important; |
|||
height: 100% !important; |
|||
max-width: 100vw !important; |
|||
} |
|||
|
|||
.ant-modal-content { |
|||
width: 100% !important; |
|||
height: 100% !important; |
|||
} |
|||
} |
|||
|
|||
.ant-modal { |
|||
position: fixed; |
|||
padding: 0; |
|||
min-height: 200px; |
|||
min-width: 200px; |
|||
|
|||
.ant-modal-close { |
|||
top: 6px; |
|||
right: 6px; |
|||
|
|||
&:hover, |
|||
&:focus { |
|||
color: rgba(0, 0, 0, 0.45); |
|||
} |
|||
|
|||
.ant-space-item:hover .anticon, |
|||
.ant-space-item:focus .anticon { |
|||
color: rgba(0, 0, 0, 0.75); |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.ant-modal-close-x { |
|||
width: 50px; |
|||
height: 50px; |
|||
line-height: 44px; |
|||
|
|||
.ant-space { |
|||
width: 100%; |
|||
height: 100%; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.ant-modal-content { |
|||
/* width: ~'v-bind("props.width")px'; */ |
|||
display: flex; |
|||
flex-direction: column; |
|||
width: 100%; |
|||
height: 100%; |
|||
min-height: 200px; |
|||
min-width: 200px; |
|||
overflow: hidden; |
|||
|
|||
.ant-modal-body { |
|||
flex: auto; |
|||
overflow: auto; |
|||
height: 100%; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,13 @@ |
|||
import DynamicTable from './src/dynamic-table.vue'; |
|||
import type { DefineComponent, Ref } from 'vue'; |
|||
import type { DynamicTableInstance, DynamicTableProps } from './src/dynamic-table'; |
|||
export { DynamicTable }; |
|||
|
|||
export * from './src/types/'; |
|||
export * from './src/hooks/'; |
|||
export * from './src/dynamic-table'; |
|||
|
|||
export type DynamicTableRef = Ref<DynamicTableInstance>; |
|||
|
|||
// TODO 暂时是为了解决ts error(如果你有解决方法 请务必联系我~): JSX element type 'DynamicTable' does not have any construct or call signatures.
|
|||
export default DynamicTable as unknown as DefineComponent<Partial<DynamicTableProps>>; |
|||
@ -0,0 +1,190 @@ |
|||
<template> |
|||
<Spin :spinning="saving"> |
|||
<div class="editable-cell"> |
|||
<Popover :visible="!!errorMsgs?.length" placement="topRight"> |
|||
<template #content> |
|||
<template v-for="err in errorMsgs" :key="err"> |
|||
<a-typography-text type="danger">{{ err }}</a-typography-text> |
|||
</template> |
|||
</template> |
|||
<a-row type="flex" :gutter="[8]"> |
|||
<SchemaFormItem |
|||
v-if="(getIsEditable || getIsCellEdit) && getSchema" |
|||
v-model:form-model="editFormModel" |
|||
:schema="getSchema" |
|||
:table-instance="tableContext" |
|||
:table-row-key="rowKey" |
|||
> |
|||
<template v-for="item in Object.keys($slots)" #[item]="data" :key="item"> |
|||
<slot :name="item" v-bind="data || {}"></slot> |
|||
</template> |
|||
</SchemaFormItem> |
|||
<a-col v-if="getIsCellEdit" :span="4" class="!flex items-center"> |
|||
<CheckOutlined @click="handleSaveCell" /> |
|||
<CloseOutlined @click="handleCancelSaveCell" /> |
|||
</a-col> |
|||
</a-row> |
|||
</Popover> |
|||
<template v-if="!isCellEdit && editableType === 'cell'"> |
|||
<slot /> |
|||
<EditOutlined @click="startEditCell" /> |
|||
</template> |
|||
</div> |
|||
</Spin> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { defineComponent, computed, ref } from 'vue'; |
|||
import { EditOutlined, CloseOutlined, CheckOutlined } from '@ant-design/icons-vue'; |
|||
import { Popover, Typography, Row, Col, Spin } from 'ant-design-vue'; |
|||
import { useTableContext } from '../../hooks'; |
|||
import type { PropType } from 'vue'; |
|||
import type { CustomRenderParams, EditableType } from '@/components/core/dynamic-table/src/types'; |
|||
import { schemaFormItemProps, SchemaFormItem } from '@/components/core/schema-form'; |
|||
import { isAsyncFunction } from '@/utils/is'; |
|||
|
|||
export default defineComponent({ |
|||
name: 'EditableCell', |
|||
components: { |
|||
SchemaFormItem, |
|||
EditOutlined, |
|||
CloseOutlined, |
|||
CheckOutlined, |
|||
Popover, |
|||
Spin, |
|||
[Col.name]: Col, |
|||
[Row.name]: Row, |
|||
'a-typography-text': Typography.Text, |
|||
}, |
|||
props: { |
|||
...schemaFormItemProps, |
|||
rowKey: [String, Number] as PropType<Key>, |
|||
editableType: [String] as PropType<EditableType>, |
|||
column: [Object] as PropType<CustomRenderParams>, |
|||
}, |
|||
setup(props) { |
|||
const saving = ref(false); |
|||
const isCellEdit = ref(props.column?.column?.defaultEditable); |
|||
|
|||
const tableContext = useTableContext(); |
|||
const { |
|||
editFormModel, |
|||
editTableFormRef, |
|||
editFormErrorMsgs, |
|||
editableCellKeys, |
|||
isEditable, |
|||
startCellEditable, |
|||
cancelCellEditable, |
|||
validateCell, |
|||
} = tableContext; |
|||
|
|||
const dataIndex = computed(() => { |
|||
return String(props.column?.column?.dataIndex); |
|||
}); |
|||
|
|||
const getSchema = computed(() => { |
|||
const field = props.schema.field; |
|||
const schema = editTableFormRef.value?.getSchemaByFiled(field) || props.schema; |
|||
return { |
|||
...schema, |
|||
colProps: { |
|||
...schema.colProps, |
|||
span: props.editableType === 'cell' ? 20 : 24, |
|||
}, |
|||
}; |
|||
}); |
|||
|
|||
const getIsEditable = computed(() => { |
|||
return props.rowKey && isEditable(props.rowKey); |
|||
}); |
|||
|
|||
const getIsCellEdit = computed(() => { |
|||
const { rowKey } = props; |
|||
return ( |
|||
isCellEdit.value && |
|||
props.editableType === 'cell' && |
|||
editableCellKeys.value.has(`${rowKey}.${dataIndex.value}`) |
|||
); |
|||
}); |
|||
|
|||
const errorMsgs = computed(() => { |
|||
const field = props.schema.field; |
|||
return editFormErrorMsgs.value.get(field); |
|||
}); |
|||
|
|||
const startEditCell = () => { |
|||
startCellEditable(props.rowKey!, dataIndex.value, props.column?.record); |
|||
isCellEdit.value = true; |
|||
}; |
|||
|
|||
const handleSaveCell = async () => { |
|||
const { rowKey, column } = props; |
|||
await validateCell(rowKey!, dataIndex.value); |
|||
if (isAsyncFunction(tableContext?.onSave)) { |
|||
saving.value = true; |
|||
await tableContext |
|||
.onSave(rowKey!, editFormModel.value[rowKey!], column?.record) |
|||
.finally(() => (saving.value = false)); |
|||
cancelCellEditable(rowKey!, dataIndex.value); |
|||
isCellEdit.value = false; |
|||
} |
|||
}; |
|||
|
|||
const handleCancelSaveCell = () => { |
|||
const { rowKey, column } = props; |
|||
tableContext?.onCancel?.(rowKey!, editFormModel.value[rowKey!], column?.record); |
|||
isCellEdit.value = false; |
|||
cancelCellEditable(props.rowKey!, dataIndex.value); |
|||
}; |
|||
|
|||
// 默认开启编辑的单元格 |
|||
if (isCellEdit.value && props.editableType === 'cell') { |
|||
startEditCell(); |
|||
} |
|||
|
|||
return { |
|||
saving, |
|||
isCellEdit, |
|||
editableCellKeys, |
|||
editFormModel, |
|||
getSchema, |
|||
getIsEditable, |
|||
getIsCellEdit, |
|||
errorMsgs, |
|||
tableContext, |
|||
startEditCell, |
|||
handleSaveCell, |
|||
handleCancelSaveCell, |
|||
}; |
|||
}, |
|||
}); |
|||
</script> |
|||
|
|||
<style lang="less" scoped> |
|||
.editable-cell { |
|||
position: relative; |
|||
padding: 5px 0; |
|||
|
|||
&:hover { |
|||
.anticon-edit { |
|||
display: block; |
|||
} |
|||
} |
|||
|
|||
.anticon-edit { |
|||
display: none; |
|||
position: absolute; |
|||
top: 50%; |
|||
right: 0; |
|||
transform: translateY(-50%); |
|||
} |
|||
} |
|||
|
|||
:deep(.ant-form-item-explain) { |
|||
display: none; |
|||
} |
|||
|
|||
:deep(.ant-form-item-with-help) { |
|||
margin: 0; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,3 @@ |
|||
export { default as TableAction } from './table-action.vue'; |
|||
export { default as ToolBar } from './tool-bar/index.vue'; |
|||
export { default as EditableCell } from './editable-cell/index.vue'; |
|||
@ -0,0 +1,130 @@ |
|||
<template> |
|||
<div class="flex items-center justify-around"> |
|||
<template v-for="(actionItem, index) in actionFilters" :key="`${index}-${actionItem.label}`"> |
|||
<component |
|||
:is="actionItem.popConfirm ? 'a-popconfirm' : 'span'" |
|||
:title="actionItem.title" |
|||
v-bind="actionItem.popConfirm" |
|||
> |
|||
<div class="flex items-center"> |
|||
<SvgIcon |
|||
v-if="actionItem.icon" |
|||
:size="actionItem.size" |
|||
:name="actionItem.icon" |
|||
:color="actionItem.color" |
|||
@click="actionItem.onClick" |
|||
/> |
|||
|
|||
<a-button |
|||
v-else |
|||
type="link" |
|||
:loading="loadingMap.get(getKey(actionItem, index))" |
|||
:disabled="actionItem.disabled" |
|||
:title="actionItem.title" |
|||
@click="actionItem.onClick" |
|||
> |
|||
{{ actionItem.label }} |
|||
</a-button> |
|||
|
|||
<!-- <Divider type="vertical" class="action-divider" v-if="index < actionFilters.length - 1" /> --> |
|||
</div> |
|||
</component> |
|||
</template> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { defineComponent, computed, ref } from 'vue'; |
|||
import { Popconfirm } from 'ant-design-vue'; |
|||
import type { PropType } from 'vue'; |
|||
import type { ActionItem } from '../types/tableAction'; |
|||
import type { CustomRenderParams } from '../types/column'; |
|||
import { hasPermission } from '@/utils/permission/hasPermission'; |
|||
import { isString, isObject, isAsyncFunction, isBoolean, isFunction } from '@/utils/is'; |
|||
import { Divider } from 'ant-design-vue'; |
|||
import { SvgIcon } from '@/components/basic/svg-icon'; |
|||
export default defineComponent({ |
|||
components: { [Popconfirm.name]: Popconfirm, SvgIcon, Divider }, |
|||
props: { |
|||
actions: { |
|||
// 表格行动作 |
|||
type: Array as PropType<ActionItem[]>, |
|||
default: () => [], |
|||
}, |
|||
columnParams: { |
|||
type: Object as PropType<CustomRenderParams>, |
|||
default: () => ({}), |
|||
}, |
|||
rowKey: [String, Number] as PropType<Key>, |
|||
}, |
|||
setup(props) { |
|||
const loadingMap = ref(new Map<string, boolean>()); |
|||
|
|||
function isIfShow(action: ActionItem): boolean { |
|||
const ifShow = action.ifShow; |
|||
|
|||
let isIfShow = true; |
|||
|
|||
if (isBoolean(ifShow)) { |
|||
isIfShow = ifShow; |
|||
} |
|||
if (isFunction(ifShow)) { |
|||
isIfShow = ifShow(action); |
|||
} |
|||
return isIfShow; |
|||
} |
|||
const actionFilters = computed(() => { |
|||
return props.actions |
|||
.filter((item) => isIfShow(item)) |
|||
.filter((item) => { |
|||
const auth = item.auth; |
|||
|
|||
if (Object.is(auth, undefined)) { |
|||
return true; |
|||
} |
|||
if (isString(auth)) { |
|||
const isValid = hasPermission(auth); |
|||
item.disabled ??= !isValid; |
|||
if (item.disabled && !isValid) { |
|||
item.title = '对不起,您没有该操作权限!'; |
|||
} |
|||
return isValid; |
|||
} |
|||
if (isObject(auth)) { |
|||
const isValid = hasPermission(auth.perm); |
|||
const isDisable = auth.effect !== 'delete'; |
|||
item.disabled ??= !isValid && isDisable; |
|||
if (item.disabled && !isValid) { |
|||
item.title = '对不起,您没有该操作权限!'; |
|||
} |
|||
return isValid || isDisable; |
|||
} |
|||
}) |
|||
.map((item, index) => { |
|||
const onClick = item.onClick; |
|||
|
|||
if (isAsyncFunction(onClick)) { |
|||
item.onClick = async () => { |
|||
const key = getKey(item, index); |
|||
loadingMap.value.set(key, true); |
|||
await onClick(props.columnParams).finally(() => { |
|||
loadingMap.value.delete(key); |
|||
}); |
|||
}; |
|||
} |
|||
return item; |
|||
}); |
|||
}); |
|||
|
|||
const getKey = (actionItem: ActionItem, index: number) => { |
|||
return `${props.rowKey}${index}${actionItem.label}`; |
|||
}; |
|||
|
|||
return { |
|||
actionFilters, |
|||
loadingMap, |
|||
getKey, |
|||
}; |
|||
}, |
|||
}); |
|||
</script> |
|||