From 73a347f34f12f653cb6c5202958a7d1322656a5a Mon Sep 17 00:00:00 2001 From: pan <13329870472@163.com> Date: Tue, 12 Mar 2024 18:14:54 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E7=B3=BB=E7=BB=9F=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/en.yaml | 10 + locales/zh-CN.yaml | 10 + mock/asyncRoutes.ts | 102 +++- package.json | 2 + pnpm-lock.yaml | 17 +- src/api/system.ts | 70 +++ src/components/ReCropper/index.ts | 7 + src/components/ReCropper/src/circled.css | 8 + src/components/ReCropper/src/index.tsx | 457 +++++++++++++++ .../ReCropper/src/svg/arrow-down.svg | 1 + src/components/ReCropper/src/svg/arrow-h.svg | 1 + .../ReCropper/src/svg/arrow-left.svg | 1 + .../ReCropper/src/svg/arrow-right.svg | 1 + src/components/ReCropper/src/svg/arrow-up.svg | 1 + src/components/ReCropper/src/svg/arrow-v.svg | 1 + src/components/ReCropper/src/svg/change.svg | 1 + src/components/ReCropper/src/svg/download.svg | 1 + src/components/ReCropper/src/svg/index.ts | 31 ++ src/components/ReCropper/src/svg/reload.svg | 1 + .../ReCropper/src/svg/rotate-left.svg | 1 + .../ReCropper/src/svg/rotate-right.svg | 1 + .../ReCropper/src/svg/search-minus.svg | 1 + .../ReCropper/src/svg/search-plus.svg | 1 + src/components/ReCropper/src/svg/upload.svg | 1 + src/router/enums.ts | 20 +- src/views/monitor/logs/login/hook.tsx | 169 ++++++ src/views/monitor/logs/login/index.vue | 165 ++++++ src/views/monitor/logs/operation/hook.tsx | 174 ++++++ src/views/monitor/logs/operation/index.vue | 165 ++++++ src/views/monitor/logs/system/hook.tsx | 228 ++++++++ src/views/monitor/logs/system/index.vue | 156 ++++++ src/views/monitor/online/hook.tsx | 117 ++++ src/views/monitor/online/index.vue | 125 +++++ src/views/monitor/utils.ts | 129 +++++ src/views/system/dept/form.vue | 139 +++++ src/views/system/dept/index.vue | 166 ++++++ src/views/system/dept/utils/hook.tsx | 177 ++++++ src/views/system/dept/utils/rule.ts | 37 ++ src/views/system/dept/utils/types.ts | 16 + src/views/system/hooks.ts | 39 ++ src/views/system/menu/README.md | 26 + src/views/system/menu/form.vue | 326 +++++++++++ src/views/system/menu/index.vue | 157 ++++++ src/views/system/menu/utils/enums.ts | 94 ++++ src/views/system/menu/utils/hook.tsx | 223 ++++++++ src/views/system/menu/utils/rule.ts | 10 + src/views/system/menu/utils/types.ts | 29 + src/views/system/role/form.vue | 55 ++ src/views/system/role/index.vue | 219 ++++++++ src/views/system/role/utils/hook.tsx | 241 ++++++++ src/views/system/role/utils/rule.ts | 8 + src/views/system/role/utils/types.ts | 15 + src/views/system/user/form/index.vue | 176 ++++++ src/views/system/user/form/role.vue | 53 ++ src/views/system/user/index.vue | 273 +++++++++ src/views/system/user/svg/expand.svg | 1 + src/views/system/user/svg/unexpand.svg | 1 + src/views/system/user/tree.vue | 212 +++++++ src/views/system/user/upload.vue | 72 +++ src/views/system/user/utils/hook.tsx | 525 ++++++++++++++++++ src/views/system/user/utils/reset.css | 5 + src/views/system/user/utils/rule.ts | 39 ++ src/views/system/user/utils/types.ts | 36 ++ 63 files changed, 5535 insertions(+), 11 deletions(-) create mode 100644 src/api/system.ts create mode 100644 src/components/ReCropper/index.ts create mode 100644 src/components/ReCropper/src/circled.css create mode 100644 src/components/ReCropper/src/index.tsx create mode 100644 src/components/ReCropper/src/svg/arrow-down.svg create mode 100644 src/components/ReCropper/src/svg/arrow-h.svg create mode 100644 src/components/ReCropper/src/svg/arrow-left.svg create mode 100644 src/components/ReCropper/src/svg/arrow-right.svg create mode 100644 src/components/ReCropper/src/svg/arrow-up.svg create mode 100644 src/components/ReCropper/src/svg/arrow-v.svg create mode 100644 src/components/ReCropper/src/svg/change.svg create mode 100644 src/components/ReCropper/src/svg/download.svg create mode 100644 src/components/ReCropper/src/svg/index.ts create mode 100644 src/components/ReCropper/src/svg/reload.svg create mode 100644 src/components/ReCropper/src/svg/rotate-left.svg create mode 100644 src/components/ReCropper/src/svg/rotate-right.svg create mode 100644 src/components/ReCropper/src/svg/search-minus.svg create mode 100644 src/components/ReCropper/src/svg/search-plus.svg create mode 100644 src/components/ReCropper/src/svg/upload.svg create mode 100644 src/views/monitor/logs/login/hook.tsx create mode 100644 src/views/monitor/logs/login/index.vue create mode 100644 src/views/monitor/logs/operation/hook.tsx create mode 100644 src/views/monitor/logs/operation/index.vue create mode 100644 src/views/monitor/logs/system/hook.tsx create mode 100644 src/views/monitor/logs/system/index.vue create mode 100644 src/views/monitor/online/hook.tsx create mode 100644 src/views/monitor/online/index.vue create mode 100644 src/views/monitor/utils.ts create mode 100644 src/views/system/dept/form.vue create mode 100644 src/views/system/dept/index.vue create mode 100644 src/views/system/dept/utils/hook.tsx create mode 100644 src/views/system/dept/utils/rule.ts create mode 100644 src/views/system/dept/utils/types.ts create mode 100644 src/views/system/hooks.ts create mode 100644 src/views/system/menu/README.md create mode 100644 src/views/system/menu/form.vue create mode 100644 src/views/system/menu/index.vue create mode 100644 src/views/system/menu/utils/enums.ts create mode 100644 src/views/system/menu/utils/hook.tsx create mode 100644 src/views/system/menu/utils/rule.ts create mode 100644 src/views/system/menu/utils/types.ts create mode 100644 src/views/system/role/form.vue create mode 100644 src/views/system/role/index.vue create mode 100644 src/views/system/role/utils/hook.tsx create mode 100644 src/views/system/role/utils/rule.ts create mode 100644 src/views/system/role/utils/types.ts create mode 100644 src/views/system/user/form/index.vue create mode 100644 src/views/system/user/form/role.vue create mode 100644 src/views/system/user/index.vue create mode 100644 src/views/system/user/svg/expand.svg create mode 100644 src/views/system/user/svg/unexpand.svg create mode 100644 src/views/system/user/tree.vue create mode 100644 src/views/system/user/upload.vue create mode 100644 src/views/system/user/utils/hook.tsx create mode 100644 src/views/system/user/utils/reset.css create mode 100644 src/views/system/user/utils/rule.ts create mode 100644 src/views/system/user/utils/types.ts diff --git a/locales/en.yaml b/locales/en.yaml index 9aaa035..9f94a9d 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -33,6 +33,16 @@ menus: permissionPage: Page Permission permissionButton: Button Permission hsAbout: About + hssysManagement: System Manage + hsUser: User Manage + hsRole: Role Manage + hsSystemMenu: Menu Manage + hsDept: Dept Manage + hssysMonitor: System Monitor + hsOnlineUser: Online User + hsLoginLog: Login Log + hsOperationLog: Operation Log + hsSystemLog: System Log status: hsLoad: Loading... login: diff --git a/locales/zh-CN.yaml b/locales/zh-CN.yaml index 57be919..8fed72f 100644 --- a/locales/zh-CN.yaml +++ b/locales/zh-CN.yaml @@ -33,6 +33,16 @@ menus: permissionPage: 页面权限 permissionButton: 按钮权限 hsAbout: 关于 + hssysManagement: 系统管理 + hsUser: 用户管理 + hsRole: 角色管理 + hsSystemMenu: 菜单管理 + hsDept: 部门管理 + hssysMonitor: 系统监控 + hsOnlineUser: 在线用户 + hsLoginLog: 登录日志 + hsOperationLog: 操作日志 + hsSystemLog: 系统日志 status: hsLoad: 加载中... login: diff --git a/mock/asyncRoutes.ts b/mock/asyncRoutes.ts index ada627d..a71e45e 100644 --- a/mock/asyncRoutes.ts +++ b/mock/asyncRoutes.ts @@ -1,11 +1,111 @@ // 模拟后端动态生成路由 import { defineFakeRoute } from "vite-plugin-fake-server/client"; +import { system, monitor } from "@/router/enums"; /** * roles:页面级别权限,这里模拟二种 "admin"、"common" * admin:管理员角色 * common:普通角色 */ + +const systemManagementRouter = { + path: "/system", + meta: { + icon: "ri:settings-3-line", + title: "menus.hssysManagement", + rank: system + }, + children: [ + { + path: "/system/user/index", + name: "SystemUser", + meta: { + icon: "ri:admin-line", + title: "menus.hsUser", + roles: ["admin"] + } + }, + { + path: "/system/role/index", + name: "SystemRole", + meta: { + icon: "ri:admin-fill", + title: "menus.hsRole", + roles: ["admin"] + } + }, + { + path: "/system/menu/index", + name: "SystemMenu", + meta: { + icon: "ep:menu", + title: "menus.hsSystemMenu", + roles: ["admin"] + } + }, + { + path: "/system/dept/index", + name: "SystemDept", + meta: { + icon: "ri:git-branch-line", + title: "menus.hsDept", + roles: ["admin"] + } + } + ] +}; + +const systemMonitorRouter = { + path: "/monitor", + meta: { + icon: "ep:monitor", + title: "menus.hssysMonitor", + rank: monitor + }, + children: [ + { + path: "/monitor/online-user", + component: "monitor/online/index", + name: "OnlineUser", + meta: { + icon: "ri:user-voice-line", + title: "menus.hsOnlineUser", + roles: ["admin"] + } + }, + { + path: "/monitor/login-logs", + component: "monitor/logs/login/index", + name: "LoginLog", + meta: { + icon: "ri:window-line", + title: "menus.hsLoginLog", + roles: ["admin"] + } + }, + { + path: "/monitor/operation-logs", + component: "monitor/logs/operation/index", + name: "OperationLog", + meta: { + icon: "ri:history-fill", + title: "menus.hsOperationLog", + roles: ["admin"] + } + }, + { + path: "/monitor/system-logs", + component: "monitor/logs/system/index", + name: "SystemLog", + meta: { + icon: "ri:file-search-line", + title: "menus.hsSystemLog", + roles: ["admin"] + } + } + ] +}; + const permissionRouter = { path: "/permission", meta: { @@ -45,7 +145,7 @@ export default defineFakeRoute([ response: () => { return { success: true, - data: [permissionRouter] + data: [systemManagementRouter, systemMonitorRouter, permissionRouter] }; } } diff --git a/package.json b/package.json index 5252636..96263fb 100644 --- a/package.json +++ b/package.json @@ -53,8 +53,10 @@ "@pureadmin/utils": "^2.4.5", "@vueuse/core": "^10.9.0", "@vueuse/motion": "^2.1.0", + "@zxcvbn-ts/core": "^3.0.4", "animate.css": "^4.1.1", "axios": "^1.6.7", + "cropperjs": "^1.6.1", "dayjs": "^1.11.10", "echarts": "^5.5.0", "element-plus": "^2.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c7c32f..62cb229 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,18 @@ dependencies: '@vueuse/motion': specifier: ^2.1.0 version: 2.1.0(rollup@2.79.1)(vue@3.4.21) + '@zxcvbn-ts/core': + specifier: ^3.0.4 + version: 3.0.4 animate.css: specifier: ^4.1.1 version: 4.1.1 axios: specifier: ^1.6.7 version: 1.6.7 + cropperjs: + specifier: ^1.6.1 + version: 1.6.1 dayjs: specifier: ^1.11.10 version: 1.11.10 @@ -1931,6 +1937,12 @@ packages: uuid: 8.3.2 dev: true + /@zxcvbn-ts/core@3.0.4: + resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==} + dependencies: + fastest-levenshtein: 1.0.16 + dev: false + /JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -2487,6 +2499,10 @@ packages: typescript: 5.3.3 dev: true + /cropperjs@1.6.1: + resolution: {integrity: sha512-F4wsi+XkDHCOMrHMYjrTEE4QBOrsHHN5/2VsVAaRq8P7E5z7xQpT75S+f/9WikmBEailas3+yo+6zPIomW+NOA==} + dev: false + /cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -3259,7 +3275,6 @@ packages: /fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} - dev: true /fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} diff --git a/src/api/system.ts b/src/api/system.ts new file mode 100644 index 0000000..84c4f5e --- /dev/null +++ b/src/api/system.ts @@ -0,0 +1,70 @@ +import { http } from "@/utils/http"; + +type Result = { + success: boolean; + data?: Array; +}; + +type ResultTable = { + success: boolean; + data?: { + /** 列表数据 */ + list: Array; + /** 总条目数 */ + total?: number; + /** 每页显示条目个数 */ + pageSize?: number; + /** 当前页数 */ + currentPage?: number; + }; +}; + +/** 获取系统管理-用户管理列表 */ +export const getUserList = (data?: object) => { + return http.request("post", "/user", { data }); +}; + +/** 系统管理-用户管理-获取所有角色列表 */ +export const getAllRoleList = () => { + return http.request("get", "/list-all-role"); +}; + +/** 系统管理-用户管理-根据userId,获取对应角色id列表(userId:用户id) */ +export const getRoleIds = (data?: object) => { + return http.request("post", "/list-role-ids", { data }); +}; + +/** 获取系统管理-角色管理列表 */ +export const getRoleList = (data?: object) => { + return http.request("post", "/role", { data }); +}; + +/** 获取系统管理-菜单管理列表 */ +export const getMenuList = (data?: object) => { + return http.request("post", "/menu", { data }); +}; + +/** 获取系统管理-部门管理列表 */ +export const getDeptList = (data?: object) => { + return http.request("post", "/dept", { data }); +}; + +/** 获取系统监控-在线用户列表 */ +export const getOnlineLogsList = (data?: object) => { + return http.request("post", "/online-logs", { data }); +}; + +/** 获取系统监控-登录日志列表 */ +export const getLoginLogsList = (data?: object) => { + return http.request("post", "/login-logs", { data }); +}; + +/** 获取系统监控-操作日志列表 */ +export const getOperationLogsList = (data?: object) => { + return http.request("post", "/operation-logs", { data }); +}; + +/** 获取系统监控-系统日志列表 */ +export const getSystemLogsList = (data?: object) => { + return http.request("post", "/system-logs", { data }); +}; diff --git a/src/components/ReCropper/index.ts b/src/components/ReCropper/index.ts new file mode 100644 index 0000000..62e2590 --- /dev/null +++ b/src/components/ReCropper/index.ts @@ -0,0 +1,7 @@ +import reCropper from "./src"; +import { withInstall } from "@pureadmin/utils"; + +/** 图片裁剪组件 */ +export const ReCropper = withInstall(reCropper); + +export default ReCropper; diff --git a/src/components/ReCropper/src/circled.css b/src/components/ReCropper/src/circled.css new file mode 100644 index 0000000..54c77d2 --- /dev/null +++ b/src/components/ReCropper/src/circled.css @@ -0,0 +1,8 @@ +@import "cropperjs/dist/cropper.css"; + +.re-circled { + .cropper-view-box, + .cropper-face { + border-radius: 50%; + } +} diff --git a/src/components/ReCropper/src/index.tsx b/src/components/ReCropper/src/index.tsx new file mode 100644 index 0000000..2cf8da5 --- /dev/null +++ b/src/components/ReCropper/src/index.tsx @@ -0,0 +1,457 @@ +import "./circled.css"; +import Cropper from "cropperjs"; +import { ElUpload } from "element-plus"; +import type { CSSProperties } from "vue"; +import { useEventListener } from "@vueuse/core"; +import { longpress } from "@/directives/longpress"; +import { useTippy, directive as tippy } from "vue-tippy"; +import { + ref, + unref, + computed, + type PropType, + onMounted, + onUnmounted, + defineComponent +} from "vue"; +import { + delay, + debounce, + isArray, + downloadByBase64, + useResizeObserver +} from "@pureadmin/utils"; +import { + Reload, + Upload, + ArrowH, + ArrowV, + ArrowUp, + ArrowDown, + ArrowLeft, + ChangeIcon, + ArrowRight, + RotateLeft, + SearchPlus, + RotateRight, + SearchMinus, + DownloadIcon +} from "./svg"; + +type Options = Cropper.Options; + +const defaultOptions: Options = { + aspectRatio: 1, + zoomable: true, + zoomOnTouch: true, + zoomOnWheel: true, + cropBoxMovable: true, + cropBoxResizable: true, + toggleDragModeOnDblclick: true, + autoCrop: true, + background: true, + highlight: true, + center: true, + responsive: true, + restore: true, + checkCrossOrigin: true, + checkOrientation: true, + scalable: true, + modal: true, + guides: true, + movable: true, + rotatable: true +}; + +const props = { + src: { type: String, required: true }, + alt: { type: String }, + circled: { type: Boolean, default: false }, + /** 是否可以通过点击裁剪区域关闭右键弹出的功能菜单,默认 `true` */ + isClose: { type: Boolean, default: true }, + realTimePreview: { type: Boolean, default: true }, + height: { type: [String, Number], default: "360px" }, + crossorigin: { + type: String as PropType<"" | "anonymous" | "use-credentials" | undefined>, + default: undefined + }, + imageStyle: { type: Object as PropType, default: () => ({}) }, + options: { type: Object as PropType, default: () => ({}) } +}; + +export default defineComponent({ + name: "ReCropper", + props, + setup(props, { attrs, emit }) { + const tippyElRef = ref>(); + const imgElRef = ref>(); + const cropper = ref>(); + const inCircled = ref(props.circled); + const isInClose = ref(props.isClose); + const inSrc = ref(props.src); + const isReady = ref(false); + const imgBase64 = ref(); + + let scaleX = 1; + let scaleY = 1; + + const debounceRealTimeCroppered = debounce(realTimeCroppered, 80); + + const getImageStyle = computed((): CSSProperties => { + return { + height: props.height, + maxWidth: "100%", + ...props.imageStyle + }; + }); + + const getClass = computed(() => { + return [ + attrs.class, + { + ["re-circled"]: inCircled.value + } + ]; + }); + + const iconClass = computed(() => { + return [ + "p-[6px]", + "h-[30px]", + "w-[30px]", + "outline-none", + "rounded-[4px]", + "cursor-pointer", + "hover:bg-[rgba(0,0,0,0.06)]" + ]; + }); + + const getWrapperStyle = computed((): CSSProperties => { + return { height: `${props.height}`.replace(/px/, "") + "px" }; + }); + + onMounted(init); + + onUnmounted(() => { + cropper.value?.destroy(); + isReady.value = false; + cropper.value = null; + imgBase64.value = ""; + scaleX = 1; + scaleY = 1; + }); + + useResizeObserver(tippyElRef, () => handCropper("reset")); + + async function init() { + const imgEl = unref(imgElRef); + if (!imgEl) return; + cropper.value = new Cropper(imgEl, { + ...defaultOptions, + ready: () => { + isReady.value = true; + realTimeCroppered(); + delay(400).then(() => emit("readied", cropper.value)); + }, + crop() { + debounceRealTimeCroppered(); + }, + zoom() { + debounceRealTimeCroppered(); + }, + cropmove() { + debounceRealTimeCroppered(); + }, + ...props.options + }); + } + + function realTimeCroppered() { + props.realTimePreview && croppered(); + } + + function croppered() { + if (!cropper.value) return; + const canvas = inCircled.value + ? getRoundedCanvas() + : cropper.value.getCroppedCanvas(); + // https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLCanvasElement/toBlob + canvas.toBlob(blob => { + if (!blob) return; + const fileReader: FileReader = new FileReader(); + fileReader.readAsDataURL(blob); + fileReader.onloadend = e => { + if (!e.target?.result || !blob) return; + imgBase64.value = e.target.result; + emit("cropper", { + base64: e.target.result, + blob, + info: { size: blob.size, ...cropper.value.getData() } + }); + }; + fileReader.onerror = () => { + emit("error"); + }; + }); + } + + function getRoundedCanvas() { + const sourceCanvas = cropper.value!.getCroppedCanvas(); + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d")!; + const width = sourceCanvas.width; + const height = sourceCanvas.height; + canvas.width = width; + canvas.height = height; + context.imageSmoothingEnabled = true; + context.drawImage(sourceCanvas, 0, 0, width, height); + context.globalCompositeOperation = "destination-in"; + context.beginPath(); + context.arc( + width / 2, + height / 2, + Math.min(width, height) / 2, + 0, + 2 * Math.PI, + true + ); + context.fill(); + return canvas; + } + + function handCropper(event: string, arg?: number | Array) { + if (event === "scaleX") { + scaleX = arg = scaleX === -1 ? 1 : -1; + } + + if (event === "scaleY") { + scaleY = arg = scaleY === -1 ? 1 : -1; + } + arg && isArray(arg) + ? cropper.value?.[event]?.(...arg) + : cropper.value?.[event]?.(arg); + } + + function beforeUpload(file) { + const reader = new FileReader(); + reader.readAsDataURL(file); + inSrc.value = ""; + reader.onload = e => { + inSrc.value = e.target?.result as string; + }; + reader.onloadend = () => { + init(); + }; + return false; + } + + const menuContent = defineComponent({ + directives: { + tippy, + longpress + }, + setup() { + return () => ( +
+ + + + downloadByBase64(imgBase64.value, "cropping.png")} + /> + { + inCircled.value = !inCircled.value; + realTimeCroppered(); + }} + /> + handCropper("reset")} + /> + handCropper("move", [0, -10]), "0:100"]} + /> + handCropper("move", [0, 10]), "0:100"]} + /> + handCropper("move", [-10, 0]), "0:100"]} + /> + handCropper("move", [10, 0]), "0:100"]} + /> + handCropper("scaleX", -1)} + /> + handCropper("scaleY", -1)} + /> + handCropper("rotate", -45)} + /> + handCropper("rotate", 45)} + /> + handCropper("zoom", 0.1), "0:100"]} + /> + handCropper("zoom", -0.1), "0:100"]} + /> +
+ ); + } + }); + + function onContextmenu(event) { + event.preventDefault(); + + const { show, setProps, destroy, state } = useTippy(tippyElRef, { + content: menuContent, + arrow: false, + theme: "light", + trigger: "manual", + interactive: true, + appendTo: "parent", + // hideOnClick: false, + placement: "bottom-end" + }); + + setProps({ + getReferenceClientRect: () => ({ + width: 0, + height: 0, + top: event.clientY, + bottom: event.clientY, + left: event.clientX, + right: event.clientX + }) + }); + + show(); + + if (isInClose.value) { + if (!state.value.isShown && !state.value.isVisible) return; + useEventListener(tippyElRef, "click", destroy); + } + } + + return { + inSrc, + props, + imgElRef, + tippyElRef, + getClass, + getWrapperStyle, + getImageStyle, + isReady, + croppered, + onContextmenu + }; + }, + + render() { + const { + inSrc, + isReady, + getClass, + getImageStyle, + onContextmenu, + getWrapperStyle + } = this; + const { alt, crossorigin } = this.props; + + return inSrc ? ( +
onContextmenu(event)} + > + {alt} +
+ ) : null; + } +}); diff --git a/src/components/ReCropper/src/svg/arrow-down.svg b/src/components/ReCropper/src/svg/arrow-down.svg new file mode 100644 index 0000000..36558e8 --- /dev/null +++ b/src/components/ReCropper/src/svg/arrow-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ReCropper/src/svg/arrow-h.svg b/src/components/ReCropper/src/svg/arrow-h.svg new file mode 100644 index 0000000..f955c41 --- /dev/null +++ b/src/components/ReCropper/src/svg/arrow-h.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ReCropper/src/svg/arrow-left.svg b/src/components/ReCropper/src/svg/arrow-left.svg new file mode 100644 index 0000000..5f1c01e --- /dev/null +++ b/src/components/ReCropper/src/svg/arrow-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ReCropper/src/svg/arrow-right.svg b/src/components/ReCropper/src/svg/arrow-right.svg new file mode 100644 index 0000000..1a0fe00 --- /dev/null +++ b/src/components/ReCropper/src/svg/arrow-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ReCropper/src/svg/arrow-up.svg b/src/components/ReCropper/src/svg/arrow-up.svg new file mode 100644 index 0000000..942f926 --- /dev/null +++ b/src/components/ReCropper/src/svg/arrow-up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ReCropper/src/svg/arrow-v.svg b/src/components/ReCropper/src/svg/arrow-v.svg new file mode 100644 index 0000000..bbd0476 --- /dev/null +++ b/src/components/ReCropper/src/svg/arrow-v.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ReCropper/src/svg/change.svg b/src/components/ReCropper/src/svg/change.svg new file mode 100644 index 0000000..ec3f02b --- /dev/null +++ b/src/components/ReCropper/src/svg/change.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ReCropper/src/svg/download.svg b/src/components/ReCropper/src/svg/download.svg new file mode 100644 index 0000000..854b2c9 --- /dev/null +++ b/src/components/ReCropper/src/svg/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ReCropper/src/svg/index.ts b/src/components/ReCropper/src/svg/index.ts new file mode 100644 index 0000000..1306ba7 --- /dev/null +++ b/src/components/ReCropper/src/svg/index.ts @@ -0,0 +1,31 @@ +import Reload from "./reload.svg?component"; +import Upload from "./upload.svg?component"; +import ArrowH from "./arrow-h.svg?component"; +import ArrowV from "./arrow-v.svg?component"; +import ArrowUp from "./arrow-up.svg?component"; +import ChangeIcon from "./change.svg?component"; +import ArrowDown from "./arrow-down.svg?component"; +import ArrowLeft from "./arrow-left.svg?component"; +import DownloadIcon from "./download.svg?component"; +import ArrowRight from "./arrow-right.svg?component"; +import RotateLeft from "./rotate-left.svg?component"; +import SearchPlus from "./search-plus.svg?component"; +import RotateRight from "./rotate-right.svg?component"; +import SearchMinus from "./search-minus.svg?component"; + +export { + Reload, + Upload, + ArrowH, + ArrowV, + ArrowUp, + ArrowDown, + ArrowLeft, + ChangeIcon, + ArrowRight, + RotateLeft, + SearchPlus, + RotateRight, + SearchMinus, + DownloadIcon +}; diff --git a/src/components/ReCropper/src/svg/reload.svg b/src/components/ReCropper/src/svg/reload.svg new file mode 100644 index 0000000..9f9615a --- /dev/null +++ b/src/components/ReCropper/src/svg/reload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ReCropper/src/svg/rotate-left.svg b/src/components/ReCropper/src/svg/rotate-left.svg new file mode 100644 index 0000000..bea3fc0 --- /dev/null +++ b/src/components/ReCropper/src/svg/rotate-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ReCropper/src/svg/rotate-right.svg b/src/components/ReCropper/src/svg/rotate-right.svg new file mode 100644 index 0000000..67ecdc6 --- /dev/null +++ b/src/components/ReCropper/src/svg/rotate-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ReCropper/src/svg/search-minus.svg b/src/components/ReCropper/src/svg/search-minus.svg new file mode 100644 index 0000000..7372706 --- /dev/null +++ b/src/components/ReCropper/src/svg/search-minus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ReCropper/src/svg/search-plus.svg b/src/components/ReCropper/src/svg/search-plus.svg new file mode 100644 index 0000000..5fa8ae9 --- /dev/null +++ b/src/components/ReCropper/src/svg/search-plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ReCropper/src/svg/upload.svg b/src/components/ReCropper/src/svg/upload.svg new file mode 100644 index 0000000..a008019 --- /dev/null +++ b/src/components/ReCropper/src/svg/upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/router/enums.ts b/src/router/enums.ts index 5ee4721..9670bff 100644 --- a/src/router/enums.ts +++ b/src/router/enums.ts @@ -11,15 +11,16 @@ const home = 0, // 平台规定只有 home 路由的 rank 才能为 0 ,所以 nested = 8, permission = 9, system = 10, - tabs = 11, - about = 12, - editor = 13, - flowchart = 14, - formdesign = 15, - board = 16, - ppt = 17, - guide = 18, - menuoverflow = 19; + monitor = 11, + tabs = 12, + about = 13, + editor = 14, + flowchart = 15, + formdesign = 16, + board = 17, + ppt = 18, + guide = 19, + menuoverflow = 20; export { home, @@ -33,6 +34,7 @@ export { nested, permission, system, + monitor, tabs, about, editor, diff --git a/src/views/monitor/logs/login/hook.tsx b/src/views/monitor/logs/login/hook.tsx new file mode 100644 index 0000000..88f31bb --- /dev/null +++ b/src/views/monitor/logs/login/hook.tsx @@ -0,0 +1,169 @@ +import dayjs from "dayjs"; +import { message } from "@/utils/message"; +import { getKeyList } from "@pureadmin/utils"; +import { getLoginLogsList } from "@/api/system"; +import { usePublicHooks } from "@/views/system/hooks"; +import type { PaginationProps } from "@pureadmin/table"; +import { type Ref, reactive, ref, onMounted, toRaw } from "vue"; + +export function useRole(tableRef: Ref) { + const form = reactive({ + username: "", + status: "", + loginTime: "" + }); + const dataList = ref([]); + const loading = ref(true); + const selectedNum = ref(0); + const { tagStyle } = usePublicHooks(); + + const pagination = reactive({ + total: 0, + pageSize: 10, + currentPage: 1, + background: true + }); + const columns: TableColumnList = [ + { + label: "勾选列", // 如果需要表格多选,此处label必须设置 + type: "selection", + fixed: "left", + reserveSelection: true // 数据刷新后保留选项 + }, + { + label: "序号", + prop: "id", + minWidth: 90 + }, + { + label: "用户名", + prop: "username", + minWidth: 100 + }, + { + label: "登录 IP", + prop: "ip", + minWidth: 140 + }, + { + label: "登录地点", + prop: "address", + minWidth: 140 + }, + { + label: "操作系统", + prop: "system", + minWidth: 100 + }, + { + label: "浏览器类型", + prop: "browser", + minWidth: 100 + }, + { + label: "登录状态", + prop: "status", + minWidth: 100, + cellRenderer: ({ row, props }) => ( + + {row.status === 1 ? "成功" : "失败"} + + ) + }, + { + label: "登录行为", + prop: "behavior", + minWidth: 100 + }, + { + label: "登录时间", + prop: "loginTime", + minWidth: 180, + formatter: ({ loginTime }) => + dayjs(loginTime).format("YYYY-MM-DD HH:mm:ss") + } + ]; + + function handleSizeChange(val: number) { + console.log(`${val} items per page`); + } + + function handleCurrentChange(val: number) { + console.log(`current page: ${val}`); + } + + /** 当CheckBox选择项发生变化时会触发该事件 */ + function handleSelectionChange(val) { + selectedNum.value = val.length; + // 重置表格高度 + tableRef.value.setAdaptive(); + } + + /** 取消选择 */ + function onSelectionCancel() { + selectedNum.value = 0; + // 用于多选表格,清空用户的选择 + tableRef.value.getTableRef().clearSelection(); + } + + /** 批量删除 */ + function onbatchDel() { + // 返回当前选中的行 + const curSelected = tableRef.value.getTableRef().getSelectionRows(); + // 接下来根据实际业务,通过选中行的某项数据,比如下面的id,调用接口进行批量删除 + message(`已删除序号为 ${getKeyList(curSelected, "id")} 的数据`, { + type: "success" + }); + tableRef.value.getTableRef().clearSelection(); + onSearch(); + } + + /** 清空日志 */ + function clearAll() { + // 根据实际业务,调用接口删除所有日志数据 + message("已删除所有日志数据", { + type: "success" + }); + onSearch(); + } + + async function onSearch() { + loading.value = true; + const { data } = await getLoginLogsList(toRaw(form)); + dataList.value = data.list; + pagination.total = data.total; + pagination.pageSize = data.pageSize; + pagination.currentPage = data.currentPage; + + setTimeout(() => { + loading.value = false; + }, 500); + } + + const resetForm = formEl => { + if (!formEl) return; + formEl.resetFields(); + onSearch(); + }; + + onMounted(() => { + onSearch(); + }); + + return { + form, + loading, + columns, + dataList, + pagination, + selectedNum, + onSearch, + clearAll, + resetForm, + onbatchDel, + handleSizeChange, + onSelectionCancel, + handleCurrentChange, + handleSelectionChange + }; +} diff --git a/src/views/monitor/logs/login/index.vue b/src/views/monitor/logs/login/index.vue new file mode 100644 index 0000000..7299570 --- /dev/null +++ b/src/views/monitor/logs/login/index.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/src/views/monitor/logs/operation/hook.tsx b/src/views/monitor/logs/operation/hook.tsx new file mode 100644 index 0000000..11ffcf2 --- /dev/null +++ b/src/views/monitor/logs/operation/hook.tsx @@ -0,0 +1,174 @@ +import dayjs from "dayjs"; +import { message } from "@/utils/message"; +import { getKeyList } from "@pureadmin/utils"; +import { getOperationLogsList } from "@/api/system"; +import { usePublicHooks } from "@/views/system/hooks"; +import type { PaginationProps } from "@pureadmin/table"; +import { type Ref, reactive, ref, onMounted, toRaw } from "vue"; + +export function useRole(tableRef: Ref) { + const form = reactive({ + module: "", + status: "", + operatingTime: "" + }); + const dataList = ref([]); + const loading = ref(true); + const selectedNum = ref(0); + const { tagStyle } = usePublicHooks(); + + const pagination = reactive({ + total: 0, + pageSize: 10, + currentPage: 1, + background: true + }); + const columns: TableColumnList = [ + { + label: "勾选列", // 如果需要表格多选,此处label必须设置 + type: "selection", + fixed: "left", + reserveSelection: true // 数据刷新后保留选项 + }, + { + label: "序号", + prop: "id", + minWidth: 90 + }, + { + label: "操作人员", + prop: "username", + minWidth: 100 + }, + { + label: "所属模块", + prop: "module", + minWidth: 140 + }, + { + label: "操作概要", + prop: "summary", + minWidth: 140 + }, + { + label: "操作 IP", + prop: "ip", + minWidth: 100 + }, + { + label: "操作地点", + prop: "address", + minWidth: 140 + }, + { + label: "操作系统", + prop: "system", + minWidth: 100 + }, + { + label: "浏览器类型", + prop: "browser", + minWidth: 100 + }, + { + label: "操作状态", + prop: "status", + minWidth: 100, + cellRenderer: ({ row, props }) => ( + + {row.status === 1 ? "成功" : "失败"} + + ) + }, + { + label: "操作时间", + prop: "operatingTime", + minWidth: 180, + formatter: ({ operatingTime }) => + dayjs(operatingTime).format("YYYY-MM-DD HH:mm:ss") + } + ]; + + function handleSizeChange(val: number) { + console.log(`${val} items per page`); + } + + function handleCurrentChange(val: number) { + console.log(`current page: ${val}`); + } + + /** 当CheckBox选择项发生变化时会触发该事件 */ + function handleSelectionChange(val) { + selectedNum.value = val.length; + // 重置表格高度 + tableRef.value.setAdaptive(); + } + + /** 取消选择 */ + function onSelectionCancel() { + selectedNum.value = 0; + // 用于多选表格,清空用户的选择 + tableRef.value.getTableRef().clearSelection(); + } + + /** 批量删除 */ + function onbatchDel() { + // 返回当前选中的行 + const curSelected = tableRef.value.getTableRef().getSelectionRows(); + // 接下来根据实际业务,通过选中行的某项数据,比如下面的id,调用接口进行批量删除 + message(`已删除序号为 ${getKeyList(curSelected, "id")} 的数据`, { + type: "success" + }); + tableRef.value.getTableRef().clearSelection(); + onSearch(); + } + + /** 清空日志 */ + function clearAll() { + // 根据实际业务,调用接口删除所有日志数据 + message("已删除所有日志数据", { + type: "success" + }); + onSearch(); + } + + async function onSearch() { + loading.value = true; + const { data } = await getOperationLogsList(toRaw(form)); + dataList.value = data.list; + pagination.total = data.total; + pagination.pageSize = data.pageSize; + pagination.currentPage = data.currentPage; + + setTimeout(() => { + loading.value = false; + }, 500); + } + + const resetForm = formEl => { + if (!formEl) return; + formEl.resetFields(); + onSearch(); + }; + + onMounted(() => { + onSearch(); + }); + + return { + form, + loading, + columns, + dataList, + pagination, + selectedNum, + onSearch, + clearAll, + resetForm, + onbatchDel, + handleSizeChange, + onSelectionCancel, + handleCurrentChange, + handleSelectionChange + }; +} diff --git a/src/views/monitor/logs/operation/index.vue b/src/views/monitor/logs/operation/index.vue new file mode 100644 index 0000000..1a95dba --- /dev/null +++ b/src/views/monitor/logs/operation/index.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/src/views/monitor/logs/system/hook.tsx b/src/views/monitor/logs/system/hook.tsx new file mode 100644 index 0000000..e78c434 --- /dev/null +++ b/src/views/monitor/logs/system/hook.tsx @@ -0,0 +1,228 @@ +import dayjs from "dayjs"; +import { message } from "@/utils/message"; +import { getSystemLogsList } from "@/api/system"; +import type { PaginationProps } from "@pureadmin/table"; +import { type Ref, reactive, ref, onMounted, toRaw } from "vue"; +import { getKeyList, useCopyToClipboard } from "@pureadmin/utils"; +import Info from "@iconify-icons/ri/question-line"; + +export function useRole(tableRef: Ref) { + const form = reactive({ + module: "", + requestTime: "" + }); + const dataList = ref([]); + const loading = ref(true); + const selectedNum = ref(0); + const { copied, update } = useCopyToClipboard(); + + const pagination = reactive({ + total: 0, + pageSize: 10, + currentPage: 1, + background: true + }); + + // const getLevelType = (type, text = false) => { + // switch (type) { + // case 0: + // return text ? "debug" : "primary"; + // case 1: + // return text ? "info" : "success"; + // case 2: + // return text ? "warn" : "info"; + // case 3: + // return text ? "error" : "warning"; + // case 4: + // return text ? "fatal" : "danger"; + // } + // }; + + const columns: TableColumnList = [ + { + label: "勾选列", // 如果需要表格多选,此处label必须设置 + type: "selection", + fixed: "left", + reserveSelection: true // 数据刷新后保留选项 + }, + { + label: "ID", + prop: "id", + minWidth: 90 + }, + { + label: "所属模块", + prop: "module", + minWidth: 100 + }, + { + headerRenderer: () => ( + + 请求接口 + + + ), + prop: "url", + minWidth: 140 + }, + { + label: "请求方法", + prop: "method", + minWidth: 140 + }, + { + label: "IP 地址", + prop: "ip", + minWidth: 100 + }, + { + label: "地点", + prop: "address", + minWidth: 140 + }, + { + label: "操作系统", + prop: "system", + minWidth: 100 + }, + { + label: "浏览器类型", + prop: "browser", + minWidth: 100 + }, + // { + // label: "级别", + // prop: "level", + // minWidth: 90, + // cellRenderer: ({ row, props }) => ( + // + // {getLevelType(row.level, true)} + // + // ) + // }, + { + label: "请求耗时", + prop: "takesTime", + minWidth: 100, + cellRenderer: ({ row, props }) => ( + + {row.takesTime} ms + + ) + }, + { + label: "请求时间", + prop: "requestTime", + minWidth: 180, + formatter: ({ requestTime }) => + dayjs(requestTime).format("YYYY-MM-DD HH:mm:ss") + } + // { + // label: "操作", + // fixed: "right", + // slot: "operation" + // } + ]; + + function handleSizeChange(val: number) { + console.log(`${val} items per page`); + } + + function handleCurrentChange(val: number) { + console.log(`current page: ${val}`); + } + + /** 当CheckBox选择项发生变化时会触发该事件 */ + function handleSelectionChange(val) { + selectedNum.value = val.length; + // 重置表格高度 + tableRef.value.setAdaptive(); + } + + /** 取消选择 */ + function onSelectionCancel() { + selectedNum.value = 0; + // 用于多选表格,清空用户的选择 + tableRef.value.getTableRef().clearSelection(); + } + + /** 拷贝请求接口,表格单元格被双击时触发 */ + function handleCellDblclick({ url }) { + update(url); + copied.value + ? message(`${url} 已拷贝`, { type: "success" }) + : message("拷贝失败", { type: "warning" }); + } + + /** 批量删除 */ + function onbatchDel() { + // 返回当前选中的行 + const curSelected = tableRef.value.getTableRef().getSelectionRows(); + // 接下来根据实际业务,通过选中行的某项数据,比如下面的id,调用接口进行批量删除 + message(`已删除序号为 ${getKeyList(curSelected, "id")} 的数据`, { + type: "success" + }); + tableRef.value.getTableRef().clearSelection(); + onSearch(); + } + + /** 清空日志 */ + function clearAll() { + // 根据实际业务,调用接口删除所有日志数据 + message("已删除所有日志数据", { + type: "success" + }); + onSearch(); + } + + async function onSearch() { + loading.value = true; + const { data } = await getSystemLogsList(toRaw(form)); + dataList.value = data.list; + pagination.total = data.total; + pagination.pageSize = data.pageSize; + pagination.currentPage = data.currentPage; + + setTimeout(() => { + loading.value = false; + }, 500); + } + + const resetForm = formEl => { + if (!formEl) return; + formEl.resetFields(); + onSearch(); + }; + + onMounted(() => { + onSearch(); + }); + + return { + form, + loading, + columns, + dataList, + pagination, + selectedNum, + onSearch, + clearAll, + resetForm, + onbatchDel, + handleSizeChange, + onSelectionCancel, + handleCellDblclick, + handleCurrentChange, + handleSelectionChange + }; +} diff --git a/src/views/monitor/logs/system/index.vue b/src/views/monitor/logs/system/index.vue new file mode 100644 index 0000000..691b786 --- /dev/null +++ b/src/views/monitor/logs/system/index.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/src/views/monitor/online/hook.tsx b/src/views/monitor/online/hook.tsx new file mode 100644 index 0000000..65b5936 --- /dev/null +++ b/src/views/monitor/online/hook.tsx @@ -0,0 +1,117 @@ +import dayjs from "dayjs"; +import { message } from "@/utils/message"; +import { getOnlineLogsList } from "@/api/system"; +import { reactive, ref, onMounted, toRaw } from "vue"; +import type { PaginationProps } from "@pureadmin/table"; + +export function useRole() { + const form = reactive({ + username: "" + }); + const dataList = ref([]); + const loading = ref(true); + const pagination = reactive({ + total: 0, + pageSize: 10, + currentPage: 1, + background: true + }); + const columns: TableColumnList = [ + { + label: "序号", + prop: "id", + minWidth: 60 + }, + { + label: "用户名", + prop: "username", + minWidth: 100 + }, + { + label: "登录 IP", + prop: "ip", + minWidth: 140 + }, + { + label: "登录地点", + prop: "address", + minWidth: 140 + }, + { + label: "操作系统", + prop: "system", + minWidth: 100 + }, + { + label: "浏览器类型", + prop: "browser", + minWidth: 100 + }, + { + label: "登录时间", + prop: "loginTime", + minWidth: 180, + formatter: ({ loginTime }) => + dayjs(loginTime).format("YYYY-MM-DD HH:mm:ss") + }, + { + label: "操作", + fixed: "right", + slot: "operation" + } + ]; + + function handleSizeChange(val: number) { + console.log(`${val} items per page`); + } + + function handleCurrentChange(val: number) { + console.log(`current page: ${val}`); + } + + function handleSelectionChange(val) { + console.log("handleSelectionChange", val); + } + + function handleOffline(row) { + message(`${row.username}已被强制下线`, { type: "success" }); + onSearch(); + } + + async function onSearch() { + loading.value = true; + const { data } = await getOnlineLogsList(toRaw(form)); + dataList.value = data.list; + pagination.total = data.total; + pagination.pageSize = data.pageSize; + pagination.currentPage = data.currentPage; + + setTimeout(() => { + loading.value = false; + }, 500); + } + + const resetForm = formEl => { + if (!formEl) return; + formEl.resetFields(); + onSearch(); + }; + + onMounted(() => { + onSearch(); + }); + + return { + form, + loading, + columns, + dataList, + pagination, + onSearch, + resetForm, + handleOffline, + handleSizeChange, + handleCurrentChange, + handleSelectionChange + }; +} diff --git a/src/views/monitor/online/index.vue b/src/views/monitor/online/index.vue new file mode 100644 index 0000000..4b92715 --- /dev/null +++ b/src/views/monitor/online/index.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/src/views/monitor/utils.ts b/src/views/monitor/utils.ts new file mode 100644 index 0000000..1350606 --- /dev/null +++ b/src/views/monitor/utils.ts @@ -0,0 +1,129 @@ +/** 日期、时间选择器快捷选项,常搭配 [DatePicker](https://element-plus.org/zh-CN/component/date-picker.html) 和 [DateTimePicker](https://element-plus.org/zh-CN/component/datetime-picker.html) 的`shortcuts`属性使用 */ +export const getPickerShortcuts = (): Array<{ + text: string; + value: Date | Function; +}> => { + return [ + { + text: "今天", + value: () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayEnd = new Date(); + todayEnd.setHours(23, 59, 59, 999); + return [today, todayEnd]; + } + }, + { + text: "昨天", + value: () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + const yesterdayEnd = new Date(); + yesterdayEnd.setDate(yesterdayEnd.getDate() - 1); + yesterdayEnd.setHours(23, 59, 59, 999); + return [yesterday, yesterdayEnd]; + } + }, + { + text: "前天", + value: () => { + const beforeYesterday = new Date(); + beforeYesterday.setDate(beforeYesterday.getDate() - 2); + beforeYesterday.setHours(0, 0, 0, 0); + const beforeYesterdayEnd = new Date(); + beforeYesterdayEnd.setDate(beforeYesterdayEnd.getDate() - 2); + beforeYesterdayEnd.setHours(23, 59, 59, 999); + return [beforeYesterday, beforeYesterdayEnd]; + } + }, + { + text: "本周", + value: () => { + const today = new Date(); + const startOfWeek = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - today.getDay() + (today.getDay() === 0 ? -6 : 1) + ); + startOfWeek.setHours(0, 0, 0, 0); + const endOfWeek = new Date( + startOfWeek.getTime() + + 6 * 24 * 60 * 60 * 1000 + + 23 * 60 * 60 * 1000 + + 59 * 60 * 1000 + + 59 * 1000 + + 999 + ); + return [startOfWeek, endOfWeek]; + } + }, + { + text: "上周", + value: () => { + const today = new Date(); + const startOfLastWeek = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - today.getDay() - 7 + (today.getDay() === 0 ? -6 : 1) + ); + startOfLastWeek.setHours(0, 0, 0, 0); + const endOfLastWeek = new Date( + startOfLastWeek.getTime() + + 6 * 24 * 60 * 60 * 1000 + + 23 * 60 * 60 * 1000 + + 59 * 60 * 1000 + + 59 * 1000 + + 999 + ); + return [startOfLastWeek, endOfLastWeek]; + } + }, + { + text: "本月", + value: () => { + const today = new Date(); + const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + startOfMonth.setHours(0, 0, 0, 0); + const endOfMonth = new Date( + today.getFullYear(), + today.getMonth() + 1, + 0 + ); + endOfMonth.setHours(23, 59, 59, 999); + return [startOfMonth, endOfMonth]; + } + }, + { + text: "上个月", + value: () => { + const today = new Date(); + const startOfLastMonth = new Date( + today.getFullYear(), + today.getMonth() - 1, + 1 + ); + startOfLastMonth.setHours(0, 0, 0, 0); + const endOfLastMonth = new Date( + today.getFullYear(), + today.getMonth(), + 0 + ); + endOfLastMonth.setHours(23, 59, 59, 999); + return [startOfLastMonth, endOfLastMonth]; + } + }, + { + text: "本年", + value: () => { + const today = new Date(); + const startOfYear = new Date(today.getFullYear(), 0, 1); + startOfYear.setHours(0, 0, 0, 0); + const endOfYear = new Date(today.getFullYear(), 11, 31); + endOfYear.setHours(23, 59, 59, 999); + return [startOfYear, endOfYear]; + } + } + ]; +}; diff --git a/src/views/system/dept/form.vue b/src/views/system/dept/form.vue new file mode 100644 index 0000000..2cec5fc --- /dev/null +++ b/src/views/system/dept/form.vue @@ -0,0 +1,139 @@ + + + diff --git a/src/views/system/dept/index.vue b/src/views/system/dept/index.vue new file mode 100644 index 0000000..d9d3e2e --- /dev/null +++ b/src/views/system/dept/index.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/src/views/system/dept/utils/hook.tsx b/src/views/system/dept/utils/hook.tsx new file mode 100644 index 0000000..052fcee --- /dev/null +++ b/src/views/system/dept/utils/hook.tsx @@ -0,0 +1,177 @@ +import dayjs from "dayjs"; +import editForm from "../form.vue"; +import { handleTree } from "@/utils/tree"; +import { message } from "@/utils/message"; +import { getDeptList } from "@/api/system"; +import { usePublicHooks } from "../../hooks"; +import { addDialog } from "@/components/ReDialog"; +import { reactive, ref, onMounted, h } from "vue"; +import type { FormItemProps } from "../utils/types"; +import { cloneDeep, isAllEmpty } from "@pureadmin/utils"; + +export function useDept() { + const form = reactive({ + name: "", + status: null + }); + + const formRef = ref(); + const dataList = ref([]); + const loading = ref(true); + const { tagStyle } = usePublicHooks(); + + const columns: TableColumnList = [ + { + label: "部门名称", + prop: "name", + width: 180, + align: "left" + }, + { + label: "排序", + prop: "sort", + minWidth: 70 + }, + { + label: "状态", + prop: "status", + minWidth: 100, + cellRenderer: ({ row, props }) => ( + + {row.status === 1 ? "启用" : "停用"} + + ) + }, + { + label: "创建时间", + minWidth: 200, + prop: "createTime", + formatter: ({ createTime }) => + dayjs(createTime).format("YYYY-MM-DD HH:mm:ss") + }, + { + label: "备注", + prop: "remark", + minWidth: 320 + }, + { + label: "操作", + fixed: "right", + width: 210, + slot: "operation" + } + ]; + + function handleSelectionChange(val) { + console.log("handleSelectionChange", val); + } + + function resetForm(formEl) { + if (!formEl) return; + formEl.resetFields(); + onSearch(); + } + + async function onSearch() { + loading.value = true; + const { data } = await getDeptList(); // 这里是返回一维数组结构,前端自行处理成树结构,返回格式要求:唯一id加父节点parentId,parentId取父节点id + let newData = data; + if (!isAllEmpty(form.name)) { + // 前端搜索部门名称 + newData = newData.filter(item => item.name.includes(form.name)); + } + if (!isAllEmpty(form.status)) { + // 前端搜索状态 + newData = newData.filter(item => item.status === form.status); + } + dataList.value = handleTree(newData); // 处理成树结构 + setTimeout(() => { + loading.value = false; + }, 500); + } + + function formatHigherDeptOptions(treeList) { + // 根据返回数据的status字段值判断追加是否禁用disabled字段,返回处理后的树结构,用于上级部门级联选择器的展示(实际开发中也是如此,不可能前端需要的每个字段后端都会返回,这时需要前端自行根据后端返回的某些字段做逻辑处理) + if (!treeList || !treeList.length) return; + const newTreeList = []; + for (let i = 0; i < treeList.length; i++) { + treeList[i].disabled = treeList[i].status === 0 ? true : false; + formatHigherDeptOptions(treeList[i].children); + newTreeList.push(treeList[i]); + } + return newTreeList; + } + + function openDialog(title = "新增", row?: FormItemProps) { + addDialog({ + title: `${title}部门`, + props: { + formInline: { + higherDeptOptions: formatHigherDeptOptions(cloneDeep(dataList.value)), + parentId: row?.parentId ?? 0, + name: row?.name ?? "", + principal: row?.principal ?? "", + phone: row?.phone ?? "", + email: row?.email ?? "", + sort: row?.sort ?? 0, + status: row?.status ?? 1, + remark: row?.remark ?? "" + } + }, + width: "40%", + draggable: true, + fullscreenIcon: true, + closeOnClickModal: false, + contentRenderer: () => h(editForm, { ref: formRef }), + beforeSure: (done, { options }) => { + const FormRef = formRef.value.getRef(); + const curData = options.props.formInline as FormItemProps; + function chores() { + message(`您${title}了部门名称为${curData.name}的这条数据`, { + type: "success" + }); + done(); // 关闭弹框 + onSearch(); // 刷新表格数据 + } + FormRef.validate(valid => { + if (valid) { + console.log("curData", curData); + // 表单规则校验通过 + if (title === "新增") { + // 实际开发先调用新增接口,再进行下面操作 + chores(); + } else { + // 实际开发先调用修改接口,再进行下面操作 + chores(); + } + } + }); + } + }); + } + + function handleDelete(row) { + message(`您删除了部门名称为${row.name}的这条数据`, { type: "success" }); + onSearch(); + } + + onMounted(() => { + onSearch(); + }); + + return { + form, + loading, + columns, + dataList, + /** 搜索 */ + onSearch, + /** 重置 */ + resetForm, + /** 新增、修改部门 */ + openDialog, + /** 删除部门 */ + handleDelete, + handleSelectionChange + }; +} diff --git a/src/views/system/dept/utils/rule.ts b/src/views/system/dept/utils/rule.ts new file mode 100644 index 0000000..b20bf67 --- /dev/null +++ b/src/views/system/dept/utils/rule.ts @@ -0,0 +1,37 @@ +import { reactive } from "vue"; +import type { FormRules } from "element-plus"; +import { isPhone, isEmail } from "@pureadmin/utils"; + +/** 自定义表单规则校验 */ +export const formRules = reactive({ + name: [{ required: true, message: "部门名称为必填项", trigger: "blur" }], + phone: [ + { + validator: (rule, value, callback) => { + if (value === "") { + callback(); + } else if (!isPhone(value)) { + callback(new Error("请输入正确的手机号码格式")); + } else { + callback(); + } + }, + trigger: "blur" + // trigger: "click" // 如果想在点击确定按钮时触发这个校验,trigger 设置成 click 即可 + } + ], + email: [ + { + validator: (rule, value, callback) => { + if (value === "") { + callback(); + } else if (!isEmail(value)) { + callback(new Error("请输入正确的邮箱格式")); + } else { + callback(); + } + }, + trigger: "blur" + } + ] +}); diff --git a/src/views/system/dept/utils/types.ts b/src/views/system/dept/utils/types.ts new file mode 100644 index 0000000..7547d6b --- /dev/null +++ b/src/views/system/dept/utils/types.ts @@ -0,0 +1,16 @@ +interface FormItemProps { + higherDeptOptions: Record[]; + parentId: number; + name: string; + principal: string; + phone: string | number; + email: string; + sort: number; + status: number; + remark: string; +} +interface FormProps { + formInline: FormItemProps; +} + +export type { FormItemProps, FormProps }; diff --git a/src/views/system/hooks.ts b/src/views/system/hooks.ts new file mode 100644 index 0000000..5465d94 --- /dev/null +++ b/src/views/system/hooks.ts @@ -0,0 +1,39 @@ +// 抽离可公用的工具函数等用于系统管理页面逻辑 +import { computed } from "vue"; +import { useDark } from "@pureadmin/utils"; + +export function usePublicHooks() { + const { isDark } = useDark(); + + const switchStyle = computed(() => { + return { + "--el-switch-on-color": "#6abe39", + "--el-switch-off-color": "#e84749" + }; + }); + + const tagStyle = computed(() => { + return (status: number) => { + return status === 1 + ? { + "--el-tag-text-color": isDark.value ? "#6abe39" : "#389e0d", + "--el-tag-bg-color": isDark.value ? "#172412" : "#f6ffed", + "--el-tag-border-color": isDark.value ? "#274a17" : "#b7eb8f" + } + : { + "--el-tag-text-color": isDark.value ? "#e84749" : "#cf1322", + "--el-tag-bg-color": isDark.value ? "#2b1316" : "#fff1f0", + "--el-tag-border-color": isDark.value ? "#58191c" : "#ffa39e" + }; + }; + }); + + return { + /** 当前网页是否为`dark`模式 */ + isDark, + /** 表现更鲜明的`el-switch`组件 */ + switchStyle, + /** 表现更鲜明的`el-tag`组件 */ + tagStyle + }; +} diff --git a/src/views/system/menu/README.md b/src/views/system/menu/README.md new file mode 100644 index 0000000..01be25f --- /dev/null +++ b/src/views/system/menu/README.md @@ -0,0 +1,26 @@ + + +## 字段含义 + +| 字段 | 说明 | +| :---------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `menuType` | 菜单类型(`0`代表菜单、`1`代表`iframe`、`2`代表外链、`3`代表按钮) | +| `parentId` | | +| `title` | 菜单名称(兼容国际化、非国际化,如果用国际化的写法就必须在根目录的`locales`文件夹下对应添加) | +| `name` | 路由名称(必须唯一并且和当前路由`component`字段对应的页面里用`defineOptions`包起来的`name`保持一致) | +| `path` | 路由路径 | +| `component` | 组件路径(传`component`组件路径,那么`path`可以随便写,如果不传,`component`组件路径会跟`path`保持一致) | +| `rank` | 菜单排序(平台规定只有`home`路由的`rank`才能为`0`,所以后端在返回`rank`的时候需要从非`0`开始 [点击查看更多](https://yiming_chang.gitee.io/pure-admin-doc/pages/routerMenu/#%E8%8F%9C%E5%8D%95%E6%8E%92%E5%BA%8F-rank)) | +| `redirect` | 路由重定向 | +| `icon` | 菜单图标 | +| `extraIcon` | 右侧图标 | +| `enterTransition` | 进场动画(页面加载动画) | +| `leaveTransition` | 离场动画(页面加载动画) | +| `activePath` | 菜单激活(将某个菜单激活,主要用于通过`query`或`params`传参的路由,当它们通过配置`showLink: false`后不在菜单中显示,就不会有任何菜单高亮,而通过设置`activePath`指定激活菜单即可获得高亮,`activePath`为指定激活菜单的`path`) | +| `auths` | 权限标识(按钮级别权限设置) | +| `frameSrc` | 链接地址(需要内嵌的`iframe`链接地址) | +| `frameLoading` | 加载动画(内嵌的`iframe`页面是否开启首次加载动画) | +| `keepAlive` | 缓存页面(是否缓存该路由页面,开启后会保存该页面的整体状态,刷新后会清空状态) | +| `hiddenTag` | 标签页(当前菜单名称或自定义信息禁止添加到标签页) | +| `showLink` | 菜单(是否显示该菜单) | +| `showParent` | 父级菜单(是否显示父级菜单 [点击查看更多](https://yiming_chang.gitee.io/pure-admin-doc/pages/routerMenu/#%E7%AC%AC%E4%B8%80%E7%A7%8D-%E8%AF%A5%E6%A8%A1%E5%BC%8F%E9%92%88%E5%AF%B9%E7%88%B6%E7%BA%A7%E8%8F%9C%E5%8D%95%E4%B8%8B%E5%8F%AA%E6%9C%89%E4%B8%80%E4%B8%AA%E5%AD%90%E8%8F%9C%E5%8D%95%E7%9A%84%E6%83%85%E5%86%B5-%E5%9C%A8%E5%AD%90%E8%8F%9C%E5%8D%95%E7%9A%84-meta-%E5%B1%9E%E6%80%A7%E4%B8%AD%E5%8A%A0%E4%B8%8A-showparent-true-%E5%8D%B3%E5%8F%AF)) | diff --git a/src/views/system/menu/form.vue b/src/views/system/menu/form.vue new file mode 100644 index 0000000..0c8ab8b --- /dev/null +++ b/src/views/system/menu/form.vue @@ -0,0 +1,326 @@ + + + diff --git a/src/views/system/menu/index.vue b/src/views/system/menu/index.vue new file mode 100644 index 0000000..fe40f70 --- /dev/null +++ b/src/views/system/menu/index.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/src/views/system/menu/utils/enums.ts b/src/views/system/menu/utils/enums.ts new file mode 100644 index 0000000..6eb3d5d --- /dev/null +++ b/src/views/system/menu/utils/enums.ts @@ -0,0 +1,94 @@ +import type { OptionsType } from "@/components/ReSegmented"; + +const menuTypeOptions: Array = [ + { + label: "菜单", + value: 0 + }, + { + label: "iframe", + value: 1 + }, + { + label: "外链", + value: 2 + }, + { + label: "按钮", + value: 3 + } +]; + +const showLinkOptions: Array = [ + { + label: "显示", + tip: "会在菜单中显示", + value: true + }, + { + label: "隐藏", + tip: "不会在菜单中显示", + value: false + } +]; + +const keepAliveOptions: Array = [ + { + label: "缓存", + tip: "会保存该页面的整体状态,刷新后会清空状态", + value: true + }, + { + label: "不缓存", + tip: "不会保存该页面的整体状态", + value: false + } +]; + +const hiddenTagOptions: Array = [ + { + label: "允许", + tip: "当前菜单名称或自定义信息允许添加到标签页", + value: false + }, + { + label: "禁止", + tip: "当前菜单名称或自定义信息禁止添加到标签页", + value: true + } +]; + +const showParentOptions: Array = [ + { + label: "显示", + tip: "会显示父级菜单", + value: true + }, + { + label: "隐藏", + tip: "不会显示父级菜单", + value: false + } +]; + +const frameLoadingOptions: Array = [ + { + label: "开启", + tip: "有首次加载动画", + value: true + }, + { + label: "关闭", + tip: "无首次加载动画", + value: false + } +]; + +export { + menuTypeOptions, + showLinkOptions, + keepAliveOptions, + hiddenTagOptions, + showParentOptions, + frameLoadingOptions +}; diff --git a/src/views/system/menu/utils/hook.tsx b/src/views/system/menu/utils/hook.tsx new file mode 100644 index 0000000..bf6820c --- /dev/null +++ b/src/views/system/menu/utils/hook.tsx @@ -0,0 +1,223 @@ +import editForm from "../form.vue"; +import { handleTree } from "@/utils/tree"; +import { message } from "@/utils/message"; +import { getMenuList } from "@/api/system"; +import { transformI18n } from "@/plugins/i18n"; +import { addDialog } from "@/components/ReDialog"; +import { reactive, ref, onMounted, h } from "vue"; +import type { FormItemProps } from "../utils/types"; +import { cloneDeep, isAllEmpty } from "@pureadmin/utils"; +import { useRenderIcon } from "@/components/ReIcon/src/hooks"; + +export function useMenu() { + const form = reactive({ + title: "" + }); + + const formRef = ref(); + const dataList = ref([]); + const loading = ref(true); + + const getMenuType = (type, text = false) => { + switch (type) { + case 0: + return text ? "菜单" : "primary"; + case 1: + return text ? "iframe" : "warning"; + case 2: + return text ? "外链" : "danger"; + case 3: + return text ? "按钮" : "info"; + } + }; + + const columns: TableColumnList = [ + { + label: "菜单名称", + prop: "title", + align: "left", + cellRenderer: ({ row }) => ( + <> + + {h(useRenderIcon(row.icon), { + style: { paddingTop: "1px" } + })} + + {transformI18n(row.title)} + + ) + }, + { + label: "菜单类型", + prop: "menuType", + width: 100, + cellRenderer: ({ row, props }) => ( + + {getMenuType(row.menuType, true)} + + ) + }, + { + label: "路由路径", + prop: "path" + }, + { + label: "组件路径", + prop: "component", + formatter: ({ path, component }) => + isAllEmpty(component) ? path : component + }, + { + label: "权限标识", + prop: "auths" + }, + { + label: "排序", + prop: "rank", + width: 100 + }, + { + label: "隐藏", + prop: "showLink", + formatter: ({ showLink }) => (showLink ? "否" : "是"), + width: 100 + }, + { + label: "操作", + fixed: "right", + width: 210, + slot: "operation" + } + ]; + + function handleSelectionChange(val) { + console.log("handleSelectionChange", val); + } + + function resetForm(formEl) { + if (!formEl) return; + formEl.resetFields(); + onSearch(); + } + + async function onSearch() { + loading.value = true; + const { data } = await getMenuList(); // 这里是返回一维数组结构,前端自行处理成树结构,返回格式要求:唯一id加父节点parentId,parentId取父节点id + let newData = data; + if (!isAllEmpty(form.title)) { + // 前端搜索菜单名称 + newData = newData.filter(item => + transformI18n(item.title).includes(form.title) + ); + } + dataList.value = handleTree(newData); // 处理成树结构 + setTimeout(() => { + loading.value = false; + }, 500); + } + + function formatHigherMenuOptions(treeList) { + if (!treeList || !treeList.length) return; + const newTreeList = []; + for (let i = 0; i < treeList.length; i++) { + treeList[i].title = transformI18n(treeList[i].title); + formatHigherMenuOptions(treeList[i].children); + newTreeList.push(treeList[i]); + } + return newTreeList; + } + + function openDialog(title = "新增", row?: FormItemProps) { + addDialog({ + title: `${title}菜单`, + props: { + formInline: { + menuType: row?.menuType ?? 0, + higherMenuOptions: formatHigherMenuOptions(cloneDeep(dataList.value)), + parentId: row?.parentId ?? 0, + title: row?.title ?? "", + name: row?.name ?? "", + path: row?.path ?? "", + component: row?.component ?? "", + rank: row?.rank ?? 99, + redirect: row?.redirect ?? "", + icon: row?.icon ?? "", + extraIcon: row?.extraIcon ?? "", + enterTransition: row?.enterTransition ?? "", + leaveTransition: row?.leaveTransition ?? "", + activePath: row?.activePath ?? "", + auths: row?.auths ?? "", + frameSrc: row?.frameSrc ?? "", + frameLoading: row?.frameLoading ?? true, + keepAlive: row?.keepAlive ?? false, + hiddenTag: row?.hiddenTag ?? false, + showLink: row?.showLink ?? true, + showParent: row?.showParent ?? false + } + }, + width: "45%", + draggable: true, + fullscreenIcon: true, + closeOnClickModal: false, + contentRenderer: () => h(editForm, { ref: formRef }), + beforeSure: (done, { options }) => { + const FormRef = formRef.value.getRef(); + const curData = options.props.formInline as FormItemProps; + function chores() { + message( + `您${title}了菜单名称为${transformI18n(curData.title)}的这条数据`, + { + type: "success" + } + ); + done(); // 关闭弹框 + onSearch(); // 刷新表格数据 + } + FormRef.validate(valid => { + if (valid) { + console.log("curData", curData); + // 表单规则校验通过 + if (title === "新增") { + // 实际开发先调用新增接口,再进行下面操作 + chores(); + } else { + // 实际开发先调用修改接口,再进行下面操作 + chores(); + } + } + }); + } + }); + } + + function handleDelete(row) { + message(`您删除了菜单名称为${transformI18n(row.title)}的这条数据`, { + type: "success" + }); + onSearch(); + } + + onMounted(() => { + onSearch(); + }); + + return { + form, + loading, + columns, + dataList, + /** 搜索 */ + onSearch, + /** 重置 */ + resetForm, + /** 新增、修改菜单 */ + openDialog, + /** 删除菜单 */ + handleDelete, + handleSelectionChange + }; +} diff --git a/src/views/system/menu/utils/rule.ts b/src/views/system/menu/utils/rule.ts new file mode 100644 index 0000000..90b3548 --- /dev/null +++ b/src/views/system/menu/utils/rule.ts @@ -0,0 +1,10 @@ +import { reactive } from "vue"; +import type { FormRules } from "element-plus"; + +/** 自定义表单规则校验 */ +export const formRules = reactive({ + title: [{ required: true, message: "菜单名称为必填项", trigger: "blur" }], + name: [{ required: true, message: "路由名称为必填项", trigger: "blur" }], + path: [{ required: true, message: "路由路径为必填项", trigger: "blur" }], + auths: [{ required: true, message: "权限标识为必填项", trigger: "blur" }] +}); diff --git a/src/views/system/menu/utils/types.ts b/src/views/system/menu/utils/types.ts new file mode 100644 index 0000000..7259825 --- /dev/null +++ b/src/views/system/menu/utils/types.ts @@ -0,0 +1,29 @@ +interface FormItemProps { + /** 菜单类型(0代表菜单、1代表iframe、2代表外链、3代表按钮)*/ + menuType: number; + higherMenuOptions: Record[]; + parentId: number; + title: string; + name: string; + path: string; + component: string; + rank: number; + redirect: string; + icon: string; + extraIcon: string; + enterTransition: string; + leaveTransition: string; + activePath: string; + auths: string; + frameSrc: string; + frameLoading: boolean; + keepAlive: boolean; + hiddenTag: boolean; + showLink: boolean; + showParent: boolean; +} +interface FormProps { + formInline: FormItemProps; +} + +export type { FormItemProps, FormProps }; diff --git a/src/views/system/role/form.vue b/src/views/system/role/form.vue new file mode 100644 index 0000000..65d4ef0 --- /dev/null +++ b/src/views/system/role/form.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/views/system/role/index.vue b/src/views/system/role/index.vue new file mode 100644 index 0000000..c5926d1 --- /dev/null +++ b/src/views/system/role/index.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/src/views/system/role/utils/hook.tsx b/src/views/system/role/utils/hook.tsx new file mode 100644 index 0000000..f4591b9 --- /dev/null +++ b/src/views/system/role/utils/hook.tsx @@ -0,0 +1,241 @@ +import dayjs from "dayjs"; +import editForm from "../form.vue"; +import { message } from "@/utils/message"; +import { getRoleList } from "@/api/system"; +import { ElMessageBox } from "element-plus"; +import { usePublicHooks } from "../../hooks"; +import { addDialog } from "@/components/ReDialog"; +import type { FormItemProps } from "../utils/types"; +import type { PaginationProps } from "@pureadmin/table"; +import { reactive, ref, onMounted, h, toRaw } from "vue"; + +export function useRole() { + const form = reactive({ + name: "", + code: "", + status: "" + }); + const formRef = ref(); + const dataList = ref([]); + const loading = ref(true); + const switchLoadMap = ref({}); + const { switchStyle } = usePublicHooks(); + const pagination = reactive({ + total: 0, + pageSize: 10, + currentPage: 1, + background: true + }); + const columns: TableColumnList = [ + { + label: "角色编号", + prop: "id", + minWidth: 100 + }, + { + label: "角色名称", + prop: "name", + minWidth: 120 + }, + { + label: "角色标识", + prop: "code", + minWidth: 150 + }, + { + label: "状态", + minWidth: 130, + cellRenderer: scope => ( + onChange(scope as any)} + /> + ) + }, + { + label: "备注", + prop: "remark", + minWidth: 150 + }, + { + label: "创建时间", + minWidth: 180, + prop: "createTime", + formatter: ({ createTime }) => + dayjs(createTime).format("YYYY-MM-DD HH:mm:ss") + }, + { + label: "操作", + fixed: "right", + width: 240, + slot: "operation" + } + ]; + // const buttonClass = computed(() => { + // return [ + // "!h-[20px]", + // "reset-margin", + // "!text-gray-500", + // "dark:!text-white", + // "dark:hover:!text-primary" + // ]; + // }); + + function onChange({ row, index }) { + ElMessageBox.confirm( + `确认要${ + row.status === 0 ? "停用" : "启用" + }${ + row.name + }吗?`, + "系统提示", + { + confirmButtonText: "确定", + cancelButtonText: "取消", + type: "warning", + dangerouslyUseHTMLString: true, + draggable: true + } + ) + .then(() => { + switchLoadMap.value[index] = Object.assign( + {}, + switchLoadMap.value[index], + { + loading: true + } + ); + setTimeout(() => { + switchLoadMap.value[index] = Object.assign( + {}, + switchLoadMap.value[index], + { + loading: false + } + ); + message(`已${row.status === 0 ? "停用" : "启用"}${row.name}`, { + type: "success" + }); + }, 300); + }) + .catch(() => { + row.status === 0 ? (row.status = 1) : (row.status = 0); + }); + } + + function handleDelete(row) { + message(`您删除了角色名称为${row.name}的这条数据`, { type: "success" }); + onSearch(); + } + + function handleSizeChange(val: number) { + console.log(`${val} items per page`); + } + + function handleCurrentChange(val: number) { + console.log(`current page: ${val}`); + } + + function handleSelectionChange(val) { + console.log("handleSelectionChange", val); + } + + async function onSearch() { + loading.value = true; + const { data } = await getRoleList(toRaw(form)); + dataList.value = data.list; + pagination.total = data.total; + pagination.pageSize = data.pageSize; + pagination.currentPage = data.currentPage; + + setTimeout(() => { + loading.value = false; + }, 500); + } + + const resetForm = formEl => { + if (!formEl) return; + formEl.resetFields(); + onSearch(); + }; + + function openDialog(title = "新增", row?: FormItemProps) { + addDialog({ + title: `${title}角色`, + props: { + formInline: { + name: row?.name ?? "", + code: row?.code ?? "", + remark: row?.remark ?? "" + } + }, + width: "40%", + draggable: true, + fullscreenIcon: true, + closeOnClickModal: false, + contentRenderer: () => h(editForm, { ref: formRef }), + beforeSure: (done, { options }) => { + const FormRef = formRef.value.getRef(); + const curData = options.props.formInline as FormItemProps; + function chores() { + message(`您${title}了角色名称为${curData.name}的这条数据`, { + type: "success" + }); + done(); // 关闭弹框 + onSearch(); // 刷新表格数据 + } + FormRef.validate(valid => { + if (valid) { + console.log("curData", curData); + // 表单规则校验通过 + if (title === "新增") { + // 实际开发先调用新增接口,再进行下面操作 + chores(); + } else { + // 实际开发先调用修改接口,再进行下面操作 + chores(); + } + } + }); + } + }); + } + + /** 菜单权限 */ + function handleMenu() { + message("等菜单管理页面开发后完善"); + } + + /** 数据权限 可自行开发 */ + // function handleDatabase() {} + + onMounted(() => { + onSearch(); + }); + + return { + form, + loading, + columns, + dataList, + pagination, + // buttonClass, + onSearch, + resetForm, + openDialog, + handleMenu, + handleDelete, + // handleDatabase, + handleSizeChange, + handleCurrentChange, + handleSelectionChange + }; +} diff --git a/src/views/system/role/utils/rule.ts b/src/views/system/role/utils/rule.ts new file mode 100644 index 0000000..ea1dd19 --- /dev/null +++ b/src/views/system/role/utils/rule.ts @@ -0,0 +1,8 @@ +import { reactive } from "vue"; +import type { FormRules } from "element-plus"; + +/** 自定义表单规则校验 */ +export const formRules = reactive({ + name: [{ required: true, message: "角色名称为必填项", trigger: "blur" }], + code: [{ required: true, message: "角色标识为必填项", trigger: "blur" }] +}); diff --git a/src/views/system/role/utils/types.ts b/src/views/system/role/utils/types.ts new file mode 100644 index 0000000..a17e900 --- /dev/null +++ b/src/views/system/role/utils/types.ts @@ -0,0 +1,15 @@ +// 虽然字段很少 但是抽离出来 后续有扩展字段需求就很方便了 + +interface FormItemProps { + /** 角色名称 */ + name: string; + /** 角色编号 */ + code: string; + /** 备注 */ + remark: string; +} +interface FormProps { + formInline: FormItemProps; +} + +export type { FormItemProps, FormProps }; diff --git a/src/views/system/user/form/index.vue b/src/views/system/user/form/index.vue new file mode 100644 index 0000000..f6834da --- /dev/null +++ b/src/views/system/user/form/index.vue @@ -0,0 +1,176 @@ + + + diff --git a/src/views/system/user/form/role.vue b/src/views/system/user/form/role.vue new file mode 100644 index 0000000..19adc5b --- /dev/null +++ b/src/views/system/user/form/role.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/views/system/user/index.vue b/src/views/system/user/index.vue new file mode 100644 index 0000000..8b97a28 --- /dev/null +++ b/src/views/system/user/index.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/src/views/system/user/svg/expand.svg b/src/views/system/user/svg/expand.svg new file mode 100644 index 0000000..bb41c35 --- /dev/null +++ b/src/views/system/user/svg/expand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/views/system/user/svg/unexpand.svg b/src/views/system/user/svg/unexpand.svg new file mode 100644 index 0000000..04b3e9d --- /dev/null +++ b/src/views/system/user/svg/unexpand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/views/system/user/tree.vue b/src/views/system/user/tree.vue new file mode 100644 index 0000000..adcb768 --- /dev/null +++ b/src/views/system/user/tree.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/src/views/system/user/upload.vue b/src/views/system/user/upload.vue new file mode 100644 index 0000000..561013f --- /dev/null +++ b/src/views/system/user/upload.vue @@ -0,0 +1,72 @@ + + + diff --git a/src/views/system/user/utils/hook.tsx b/src/views/system/user/utils/hook.tsx new file mode 100644 index 0000000..4bc98c5 --- /dev/null +++ b/src/views/system/user/utils/hook.tsx @@ -0,0 +1,525 @@ +import "./reset.css"; +import dayjs from "dayjs"; +import roleForm from "../form/role.vue"; +import editForm from "../form/index.vue"; +import { zxcvbn } from "@zxcvbn-ts/core"; +import { handleTree } from "@/utils/tree"; +import { message } from "@/utils/message"; +import croppingUpload from "../upload.vue"; +import { usePublicHooks } from "../../hooks"; +import { addDialog } from "@/components/ReDialog"; +import type { PaginationProps } from "@pureadmin/table"; +import type { FormItemProps, RoleFormItemProps } from "../utils/types"; +import { hideTextAtIndex, getKeyList, isAllEmpty } from "@pureadmin/utils"; +import { + getRoleIds, + getDeptList, + getUserList, + getAllRoleList +} from "@/api/system"; +import { + ElForm, + ElInput, + ElFormItem, + ElProgress, + ElMessageBox +} from "element-plus"; +import { + type Ref, + h, + ref, + toRaw, + watch, + computed, + reactive, + onMounted +} from "vue"; + +export function useUser(tableRef: Ref, treeRef: Ref) { + const form = reactive({ + // 左侧部门树的id + deptId: "", + username: "", + phone: "", + status: "" + }); + const formRef = ref(); + const ruleFormRef = ref(); + const dataList = ref([]); + const loading = ref(true); + // 上传头像信息 + const avatarInfo = ref(); + const switchLoadMap = ref({}); + const { switchStyle } = usePublicHooks(); + const higherDeptOptions = ref(); + const treeData = ref([]); + const treeLoading = ref(true); + const selectedNum = ref(0); + const pagination = reactive({ + total: 0, + pageSize: 10, + currentPage: 1, + background: true + }); + const columns: TableColumnList = [ + { + label: "勾选列", // 如果需要表格多选,此处label必须设置 + type: "selection", + fixed: "left", + reserveSelection: true // 数据刷新后保留选项 + }, + { + label: "用户编号", + prop: "id", + width: 90 + }, + { + label: "用户头像", + prop: "avatar", + cellRenderer: ({ row }) => ( + + ), + width: 90 + }, + { + label: "用户名称", + prop: "username", + minWidth: 130 + }, + { + label: "用户昵称", + prop: "nickname", + minWidth: 130 + }, + { + label: "性别", + prop: "sex", + minWidth: 90, + cellRenderer: ({ row, props }) => ( + + {row.sex === 1 ? "女" : "男"} + + ) + }, + { + label: "部门", + prop: "dept.name", + minWidth: 90 + }, + { + label: "手机号码", + prop: "phone", + minWidth: 90, + formatter: ({ phone }) => hideTextAtIndex(phone, { start: 3, end: 6 }) + }, + { + label: "状态", + prop: "status", + minWidth: 90, + cellRenderer: scope => ( + onChange(scope as any)} + /> + ) + }, + { + label: "创建时间", + minWidth: 90, + prop: "createTime", + formatter: ({ createTime }) => + dayjs(createTime).format("YYYY-MM-DD HH:mm:ss") + }, + { + label: "操作", + fixed: "right", + width: 180, + slot: "operation" + } + ]; + const buttonClass = computed(() => { + return [ + "!h-[20px]", + "reset-margin", + "!text-gray-500", + "dark:!text-white", + "dark:hover:!text-primary" + ]; + }); + // 重置的新密码 + const pwdForm = reactive({ + newPwd: "" + }); + const pwdProgress = [ + { color: "#e74242", text: "非常弱" }, + { color: "#EFBD47", text: "弱" }, + { color: "#ffa500", text: "一般" }, + { color: "#1bbf1b", text: "强" }, + { color: "#008000", text: "非常强" } + ]; + // 当前密码强度(0-4) + const curScore = ref(); + const roleOptions = ref([]); + + function onChange({ row, index }) { + ElMessageBox.confirm( + `确认要${ + row.status === 0 ? "停用" : "启用" + }${ + row.username + }用户吗?`, + "系统提示", + { + confirmButtonText: "确定", + cancelButtonText: "取消", + type: "warning", + dangerouslyUseHTMLString: true, + draggable: true + } + ) + .then(() => { + switchLoadMap.value[index] = Object.assign( + {}, + switchLoadMap.value[index], + { + loading: true + } + ); + setTimeout(() => { + switchLoadMap.value[index] = Object.assign( + {}, + switchLoadMap.value[index], + { + loading: false + } + ); + message("已成功修改用户状态", { + type: "success" + }); + }, 300); + }) + .catch(() => { + row.status === 0 ? (row.status = 1) : (row.status = 0); + }); + } + + function handleUpdate(row) { + console.log(row); + } + + function handleDelete(row) { + message(`您删除了用户编号为${row.id}的这条数据`, { type: "success" }); + onSearch(); + } + + function handleSizeChange(val: number) { + console.log(`${val} items per page`); + } + + function handleCurrentChange(val: number) { + console.log(`current page: ${val}`); + } + + /** 当CheckBox选择项发生变化时会触发该事件 */ + function handleSelectionChange(val) { + selectedNum.value = val.length; + // 重置表格高度 + tableRef.value.setAdaptive(); + } + + /** 取消选择 */ + function onSelectionCancel() { + selectedNum.value = 0; + // 用于多选表格,清空用户的选择 + tableRef.value.getTableRef().clearSelection(); + } + + /** 批量删除 */ + function onbatchDel() { + // 返回当前选中的行 + const curSelected = tableRef.value.getTableRef().getSelectionRows(); + // 接下来根据实际业务,通过选中行的某项数据,比如下面的id,调用接口进行批量删除 + message(`已删除用户编号为 ${getKeyList(curSelected, "id")} 的数据`, { + type: "success" + }); + tableRef.value.getTableRef().clearSelection(); + onSearch(); + } + + async function onSearch() { + loading.value = true; + const { data } = await getUserList(toRaw(form)); + dataList.value = data.list; + pagination.total = data.total; + pagination.pageSize = data.pageSize; + pagination.currentPage = data.currentPage; + + setTimeout(() => { + loading.value = false; + }, 500); + } + + const resetForm = formEl => { + if (!formEl) return; + formEl.resetFields(); + form.deptId = ""; + treeRef.value.onTreeReset(); + onSearch(); + }; + + function onTreeSelect({ id, selected }) { + form.deptId = selected ? id : ""; + onSearch(); + } + + function formatHigherDeptOptions(treeList) { + // 根据返回数据的status字段值判断追加是否禁用disabled字段,返回处理后的树结构,用于上级部门级联选择器的展示(实际开发中也是如此,不可能前端需要的每个字段后端都会返回,这时需要前端自行根据后端返回的某些字段做逻辑处理) + if (!treeList || !treeList.length) return; + const newTreeList = []; + for (let i = 0; i < treeList.length; i++) { + treeList[i].disabled = treeList[i].status === 0 ? true : false; + formatHigherDeptOptions(treeList[i].children); + newTreeList.push(treeList[i]); + } + return newTreeList; + } + + function openDialog(title = "新增", row?: FormItemProps) { + addDialog({ + title: `${title}用户`, + props: { + formInline: { + title, + higherDeptOptions: formatHigherDeptOptions(higherDeptOptions.value), + parentId: row?.dept.id ?? 0, + nickname: row?.nickname ?? "", + username: row?.username ?? "", + password: row?.password ?? "", + phone: row?.phone ?? "", + email: row?.email ?? "", + sex: row?.sex ?? "", + status: row?.status ?? 1, + remark: row?.remark ?? "" + } + }, + width: "46%", + draggable: true, + fullscreenIcon: true, + closeOnClickModal: false, + contentRenderer: () => h(editForm, { ref: formRef }), + beforeSure: (done, { options }) => { + const FormRef = formRef.value.getRef(); + const curData = options.props.formInline as FormItemProps; + function chores() { + message(`您${title}了用户名称为${curData.username}的这条数据`, { + type: "success" + }); + done(); // 关闭弹框 + onSearch(); // 刷新表格数据 + } + FormRef.validate(valid => { + if (valid) { + console.log("curData", curData); + // 表单规则校验通过 + if (title === "新增") { + // 实际开发先调用新增接口,再进行下面操作 + chores(); + } else { + // 实际开发先调用修改接口,再进行下面操作 + chores(); + } + } + }); + } + }); + } + + const cropRef = ref(); + /** 上传头像 */ + function handleUpload(row) { + addDialog({ + title: "裁剪、上传头像", + width: "40%", + draggable: true, + closeOnClickModal: false, + contentRenderer: () => + h(croppingUpload, { + ref: cropRef, + imgSrc: row.avatar, + onCropper: info => (avatarInfo.value = info) + }), + beforeSure: done => { + console.log("裁剪后的图片信息:", avatarInfo.value); + // 根据实际业务使用avatarInfo.value和row里的某些字段去调用上传头像接口即可 + done(); // 关闭弹框 + onSearch(); // 刷新表格数据 + }, + closeCallBack: () => cropRef.value.hidePopover() + }); + } + + watch( + pwdForm, + ({ newPwd }) => + (curScore.value = isAllEmpty(newPwd) ? -1 : zxcvbn(newPwd).score) + ); + + /** 重置密码 */ + function handleReset(row) { + addDialog({ + title: `重置 ${row.username} 用户的密码`, + width: "30%", + draggable: true, + closeOnClickModal: false, + contentRenderer: () => ( + <> + + + + + +
+ {pwdProgress.map(({ color, text }, idx) => ( +
+ = idx ? 100 : 0} + color={color} + stroke-width={10} + show-text={false} + /> +

+ {text} +

+
+ ))} +
+ + ), + closeCallBack: () => (pwdForm.newPwd = ""), + beforeSure: done => { + ruleFormRef.value.validate(valid => { + if (valid) { + // 表单规则校验通过 + message(`已成功重置 ${row.username} 用户的密码`, { + type: "success" + }); + console.log(pwdForm.newPwd); + // 根据实际业务使用pwdForm.newPwd和row里的某些字段去调用重置用户密码接口即可 + done(); // 关闭弹框 + onSearch(); // 刷新表格数据 + } + }); + } + }); + } + + /** 分配角色 */ + async function handleRole(row) { + // 选中的角色列表 + const ids = (await getRoleIds({ userId: row.id })).data ?? []; + addDialog({ + title: `分配 ${row.username} 用户的角色`, + props: { + formInline: { + username: row?.username ?? "", + nickname: row?.nickname ?? "", + roleOptions: roleOptions.value ?? [], + ids + } + }, + width: "400px", + draggable: true, + fullscreenIcon: true, + closeOnClickModal: false, + contentRenderer: () => h(roleForm), + beforeSure: (done, { options }) => { + const curData = options.props.formInline as RoleFormItemProps; + console.log("curIds", curData.ids); + // 根据实际业务使用curData.ids和row里的某些字段去调用修改角色接口即可 + done(); // 关闭弹框 + } + }); + } + + onMounted(async () => { + treeLoading.value = true; + onSearch(); + + // 归属部门 + const { data } = await getDeptList(); + higherDeptOptions.value = handleTree(data); + treeData.value = handleTree(data); + treeLoading.value = false; + + // 角色列表 + roleOptions.value = (await getAllRoleList()).data; + }); + + return { + form, + loading, + columns, + dataList, + treeData, + treeLoading, + selectedNum, + pagination, + buttonClass, + onSearch, + resetForm, + onbatchDel, + openDialog, + onTreeSelect, + handleUpdate, + handleDelete, + handleUpload, + handleReset, + handleRole, + handleSizeChange, + onSelectionCancel, + handleCurrentChange, + handleSelectionChange + }; +} diff --git a/src/views/system/user/utils/reset.css b/src/views/system/user/utils/reset.css new file mode 100644 index 0000000..97f4e4f --- /dev/null +++ b/src/views/system/user/utils/reset.css @@ -0,0 +1,5 @@ +/** 局部重置 ElProgress 的部分样式 */ +.el-progress-bar__outer, +.el-progress-bar__inner { + border-radius: 0; +} diff --git a/src/views/system/user/utils/rule.ts b/src/views/system/user/utils/rule.ts new file mode 100644 index 0000000..f946ee2 --- /dev/null +++ b/src/views/system/user/utils/rule.ts @@ -0,0 +1,39 @@ +import { reactive } from "vue"; +import type { FormRules } from "element-plus"; +import { isPhone, isEmail } from "@pureadmin/utils"; + +/** 自定义表单规则校验 */ +export const formRules = reactive({ + nickname: [{ required: true, message: "用户昵称为必填项", trigger: "blur" }], + username: [{ required: true, message: "用户名称为必填项", trigger: "blur" }], + password: [{ required: true, message: "用户密码为必填项", trigger: "blur" }], + phone: [ + { + validator: (rule, value, callback) => { + if (value === "") { + callback(); + } else if (!isPhone(value)) { + callback(new Error("请输入正确的手机号码格式")); + } else { + callback(); + } + }, + trigger: "blur" + // trigger: "click" // 如果想在点击确定按钮时触发这个校验,trigger 设置成 click 即可 + } + ], + email: [ + { + validator: (rule, value, callback) => { + if (value === "") { + callback(); + } else if (!isEmail(value)) { + callback(new Error("请输入正确的邮箱格式")); + } else { + callback(); + } + }, + trigger: "blur" + } + ] +}); diff --git a/src/views/system/user/utils/types.ts b/src/views/system/user/utils/types.ts new file mode 100644 index 0000000..c5ab88c --- /dev/null +++ b/src/views/system/user/utils/types.ts @@ -0,0 +1,36 @@ +interface FormItemProps { + id?: number; + /** 用于判断是`新增`还是`修改` */ + title: string; + higherDeptOptions: Record[]; + parentId: number; + nickname: string; + username: string; + password: string; + phone: string | number; + email: string; + sex: string | number; + status: number; + dept?: { + id?: number; + name?: string; + }; + remark: string; +} +interface FormProps { + formInline: FormItemProps; +} + +interface RoleFormItemProps { + username: string; + nickname: string; + /** 角色列表 */ + roleOptions: any[]; + /** 选中的角色列表 */ + ids: Record[]; +} +interface RoleFormProps { + formInline: RoleFormItemProps; +} + +export type { FormItemProps, FormProps, RoleFormItemProps, RoleFormProps };