Compare commits

25 Commits
main ... dev

Author SHA1 Message Date
liangdong
d722f5cbc0 add:新增商机管理-客户管理页面 2026-01-14 19:23:22 +08:00
liangdong
c77ba236e7 add 增加.npmrc文件 2026-01-14 18:43:10 +08:00
liangdong
c6d57ed0db fix:调整Table组件 完善权限管理-字典管理 2026-01-14 13:33:03 +08:00
liangdong
e4c5330a18 fix:完善proTablev2组件 2026-01-13 22:35:28 +08:00
liangdong
772e35b35b fix:修改 2026-01-13 20:32:44 +08:00
liangdong
05496ae4c4 fix:联调权限接口 2026-01-13 18:32:56 +08:00
liangdong
c6a4604d1f fix:联调成员新增接口 2026-01-12 21:24:45 +08:00
liangdong
a4bb81dc0a fix:联调权限页面模块 优化全局table组件 2026-01-12 19:28:26 +08:00
liangdong
79e16909f0 fix:登录增加记住密码 优化路由逻辑 2026-01-11 22:23:34 +08:00
liangdong
23a7285e29 add:增加vue-flow组件 2026-01-10 12:57:14 +08:00
liangdong
2a76877bdb fix:字典管理调整 2026-01-09 20:17:22 +08:00
liangdong
0a54a7affb fix:评论组件调试 权限模块前端页面开发 2026-01-09 19:23:04 +08:00
liangdong
fc84d925d6 fix:完善权限管理页面 2026-01-09 14:17:20 +08:00
liangdong
a76046e2cc add:增加权限弹窗 2026-01-08 22:45:00 +08:00
liangdong
9a2fb78f42 fix:修改table组件中的圆角 2026-01-08 18:40:05 +08:00
liangdong
6d93092f10 fix:联调评论接口、新增流程管理列表模块页面 2026-01-08 18:34:05 +08:00
liangdong
cbdc1231ce fix:修改 2026-01-07 20:21:51 +08:00
liangdong
90297a14ed fix:完善组织管理逻辑 2026-01-07 18:59:34 +08:00
liangdong
9a8a2e3064 fix:完善评论组件逻辑 2026-01-07 16:40:36 +08:00
liangdong
b7ca434172 fix:评论完善加载逻辑 2026-01-06 21:09:10 +08:00
liangdong
c21e778036 fix:调试展开收起功能 2026-01-06 19:32:21 +08:00
liangdong
65090d8dcf fix:完善评论组件 2026-01-06 19:18:38 +08:00
liangdong
a6035e5f5f fix:优化@圈人功能 2026-01-05 21:34:54 +08:00
liangdong
b03db2d89e fix:完善评论组件@圈人功能 2026-01-05 21:25:45 +08:00
liangdong
822c4e9f90 fix:修改 2026-01-05 19:28:55 +08:00
87 changed files with 9312 additions and 1617 deletions

6
.npmrc Normal file
View File

@@ -0,0 +1,6 @@
# 设置 npm 官方镜像源为淘宝镜像
registry=https://registry.npmmirror.com/
# 常见二进制依赖包的镜像地址
sass_binary_site=https://npmmirror.com/mirrors/node-sass/
electron_mirror=https://npmmirror.com/mirrors/electron/

163
Jenkinsfile vendored
View File

@@ -28,7 +28,7 @@ pipeline {
extensions : [
[$class: 'RelativeTargetDirectory', relativeTargetDir: 'docker'],
],
userRemoteConfigs: [[credentialsId: 'jenkins', url: 'https://gitlab.fengchaoit.com/basic/buildscripts.git']]
userRemoteConfigs: [[credentialsId: '82ba88dd-b6dd-46f0-8448-3cbd60bf15c2', url: 'https://gitea.zzmjart.com/mj/devopsscripts.git']]
])
sh "echo '拷贝Dockerfile文件'"
sh "cp ./docker/front/Dockerfile ."
@@ -49,9 +49,9 @@ pipeline {
stage('推送镜像到镜像仓库') {
steps {
sh "echo '改镜像标签'"
sh "docker tag $projectName:$targetVersion harbor.fengchaoit.com/$groupName/$projectName:$targetVersion"
sh "docker tag $projectName:$targetVersion 172.31.127.251:8083/$groupName/$projectName:$targetVersion"
sh "echo '镜像入库'"
sh "docker push harbor.fengchaoit.com/$groupName/$projectName:$targetVersion"
sh "docker push 172.31.127.251:8083/$groupName/$projectName:$targetVersion"
}
}
@@ -68,57 +68,57 @@ pipeline {
}
stage('保存到发布版本目录'){
when {
expression { env.branch == "main" }
}
steps {
sh "mkdir -p /projects/$groupName/$targetVersion/$projectName"
sh "rm -f /projects/$groupName/$targetVersion/$projectName/*"
sh "cp project/* /projects/$groupName/$targetVersion/$projectName/"
script {
if(Boolean.parseBoolean(proddeploy)) {
sshPublisher(
continueOnError: false,
failOnError: true,
publishers: [
sshPublisherDesc(
configName: env.branchConfig,
transfers: [
sshTransfer(
cleanRemote: false,
excludes: '',
execCommand: [
"echo 定位到项目位置",
"cd /usr/local/fengchaoit/$projectName",
"echo 授予启动脚本权限",
"chmod +x ./apprun.sh",
"echo 停止正在运行服务",
"./apprun.sh remove",
"echo 启动新构建服务",
"./apprun.sh restart"
].join('\n'),
execTimeout: 120000,
flatten: false,
makeEmptyDirs: false,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: "/usr/local/fengchaoit/$projectName",
remoteDirectorySDF: false,
removePrefix: 'project',
sourceFiles: 'project/*'
)
],
usePromotionTimestamp: false,
useWorkspaceInPromotion: false,
verbose: true
)
]
)
}
}
}
}
// stage('保存到发布版本目录'){
// when {
// expression { env.branch == "main" }
// }
// steps {
// sh "mkdir -p /projects/$groupName/$targetVersion/$projectName"
// sh "rm -f /projects/$groupName/$targetVersion/$projectName/*"
// sh "cp project/* /projects/$groupName/$targetVersion/$projectName/"
// script {
// if(Boolean.parseBoolean(proddeploy)) {
// sshPublisher(
// continueOnError: false,
// failOnError: true,
// publishers: [
// sshPublisherDesc(
// configName: env.branchConfig,
// transfers: [
// sshTransfer(
// cleanRemote: false,
// excludes: '',
// execCommand: [
// "echo 定位到项目位置",
// "cd /usr/local/fengchaoit/$projectName",
// "echo 授予启动脚本权限",
// "chmod +x ./apprun.sh",
// "echo 停止正在运行服务",
// "./apprun.sh remove",
// "echo 启动新构建服务",
// "./apprun.sh restart"
// ].join('\n'),
// execTimeout: 120000,
// flatten: false,
// makeEmptyDirs: false,
// noDefaultExcludes: false,
// patternSeparator: '[, ]+',
// remoteDirectory: "/usr/local/fengchaoit/$projectName",
// remoteDirectorySDF: false,
// removePrefix: 'project',
// sourceFiles: 'project/*'
// )
// ],
// usePromotionTimestamp: false,
// useWorkspaceInPromotion: false,
// verbose: true
// )
// ]
// )
// }
// }
// }
// }
stage('部署项目到服务器'){
@@ -138,7 +138,7 @@ pipeline {
excludes: '',
execCommand: [
"echo 定位到项目位置",
"cd /usr/local/fengchaoit/WorkSpace/$projectName",
"cd /opt/mingjiang/$projectName",
"echo 授予启动脚本权限",
"chmod +x ./apprun.sh",
"echo 停止正在运行服务",
@@ -151,7 +151,7 @@ pipeline {
makeEmptyDirs: false,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: "/usr/local/fengchaoit/WorkSpace/$projectName",
remoteDirectory: "/opt/mingjiang/$projectName",
remoteDirectorySDF: false,
removePrefix: 'project',
sourceFiles: 'project/*'
@@ -174,28 +174,37 @@ pipeline {
}
}
post {
success {
script {
if(env.branch == "main"){
sh "curl -X POST -H \"Content-Type: application/json\" -d '{\"msg_type\":\"interactive\",\"card\":{\"config\":{},\"i18n_elements\":{\"zh_cn\":[{\"tag\":\"column_set\",\"flex_mode\":\"none\",\"horizontal_spacing\":\"default\",\"background_style\":\"default\",\"columns\":[{\"tag\":\"column\",\"elements\":[{\"tag\":\"div\",\"text\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目${targetVersion}版本打包成功,请及时获取备份\",\"text_size\":\"normal\",\"text_align\":\"left\",\"text_color\":\"default\"},\"icon\":{\"tag\":\"standard_icon\",\"token\":\"announce_filled\",\"color\":\"grey\"}}],\"width\":\"weighted\",\"weight\":1}]}]},\"i18n_header\":{\"zh_cn\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"版本构建成功通知\"},\"subtitle\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目打包成功\"},\"template\":\"green\",\"ud_icon\":{\"tag\":\"standard_icon\",\"token\":\"msgcard-rectangle_outlined\"}}}}}}' https://open.feishu.cn/open-apis/bot/v2/hook/ecad4435-131f-4d49-ab2b-d12e95c9b245"
} else if(env.branch == "dev" || env.branch == "test" || env.branch =~ /(^f_.*_dev$)|(^fix_.*_test$)/) {
sh "curl -X POST -H \"Content-Type: application/json\" -d '{\"msg_type\":\"interactive\",\"card\":{\"config\":{},\"i18n_elements\":{\"zh_cn\":[{\"tag\":\"column_set\",\"flex_mode\":\"none\",\"horizontal_spacing\":\"default\",\"background_style\":\"default\",\"columns\":[{\"tag\":\"column\",\"elements\":[{\"tag\":\"div\",\"text\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目${targetVersion}版本打包成功且已部署到${env.branchConfig}环境,请及时查看验证\",\"text_size\":\"normal\",\"text_align\":\"left\",\"text_color\":\"default\"},\"icon\":{\"tag\":\"standard_icon\",\"token\":\"announce_filled\",\"color\":\"grey\"}}],\"width\":\"weighted\",\"weight\":1}]}]},\"i18n_header\":{\"zh_cn\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"版本构建成功通知\"},\"subtitle\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目部署成功\"},\"template\":\"green\",\"ud_icon\":{\"tag\":\"standard_icon\",\"token\":\"msgcard-rectangle_outlined\"}}}}}}' https://open.feishu.cn/open-apis/bot/v2/hook/ecad4435-131f-4d49-ab2b-d12e95c9b245"
} else {
sh "curl -X POST -H \"Content-Type: application/json\" -d '{\"msg_type\":\"interactive\",\"card\":{\"config\":{},\"i18n_elements\":{\"zh_cn\":[{\"tag\":\"column_set\",\"flex_mode\":\"none\",\"horizontal_spacing\":\"default\",\"background_style\":\"default\",\"columns\":[{\"tag\":\"column\",\"elements\":[{\"tag\":\"div\",\"text\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目${env.branch}分支构建成功,非有效分支无法进行远程部署,请及时检查\",\"text_size\":\"normal\",\"text_align\":\"left\",\"text_color\":\"default\"},\"icon\":{\"tag\":\"standard_icon\",\"token\":\"announce_filled\",\"color\":\"grey\"}}],\"width\":\"weighted\",\"weight\":1}]}]},\"i18n_header\":{\"zh_cn\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"版本构建成功通知\"},\"subtitle\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目构建成功\"},\"template\":\"green\",\"ud_icon\":{\"tag\":\"standard_icon\",\"token\":\"msgcard-rectangle_outlined\"}}}}}}' https://open.feishu.cn/open-apis/bot/v2/hook/ecad4435-131f-4d49-ab2b-d12e95c9b245"
}
}
}
failure {
sh "curl -X POST -H \"Content-Type: application/json\" -d '{\"msg_type\":\"interactive\",\"card\":{\"config\":{},\"i18n_elements\":{\"zh_cn\":[{\"tag\":\"column_set\",\"flex_mode\":\"none\",\"horizontal_spacing\":\"default\",\"background_style\":\"default\",\"columns\":[{\"tag\":\"column\",\"elements\":[{\"tag\":\"div\",\"text\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目${env.branch}分支构建失败,请及时查看失败原因并修复\",\"text_size\":\"normal\",\"text_align\":\"left\",\"text_color\":\"default\"},\"icon\":{\"tag\":\"standard_icon\",\"token\":\"announce_filled\",\"color\":\"grey\"}}],\"width\":\"weighted\",\"weight\":1}]}]},\"i18n_header\":{\"zh_cn\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"项目构建失败通知\"},\"subtitle\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目构建失败\"},\"template\":\"red\",\"ud_icon\":{\"tag\":\"standard_icon\",\"token\":\"close_outlined\"}}}}}}' https://open.feishu.cn/open-apis/bot/v2/hook/ecad4435-131f-4d49-ab2b-d12e95c9b245"
}
aborted {
sh "curl -X POST -H \"Content-Type: application/json\" -d '{\"msg_type\":\"interactive\",\"card\":{\"config\":{},\"i18n_elements\":{\"zh_cn\":[{\"tag\":\"column_set\",\"flex_mode\":\"none\",\"horizontal_spacing\":\"default\",\"background_style\":\"default\",\"columns\":[{\"tag\":\"column\",\"elements\":[{\"tag\":\"div\",\"text\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目${env.branch}分支构建未成功,请及时查看失败原因并修复\",\"text_size\":\"normal\",\"text_align\":\"left\",\"text_color\":\"default\"},\"icon\":{\"tag\":\"standard_icon\",\"token\":\"announce_filled\",\"color\":\"grey\"}}],\"width\":\"weighted\",\"weight\":1}]}]},\"i18n_header\":{\"zh_cn\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"项目构建未成功通知\"},\"subtitle\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目构建未成功\"},\"template\":\"orange\",\"ud_icon\":{\"tag\":\"standard_icon\",\"token\":\"warning_outlined\"}}}}}}' https://open.feishu.cn/open-apis/bot/v2/hook/ecad4435-131f-4d49-ab2b-d12e95c9b245"
}
// success {
// script {
// if(env.branch == "main"){
// sh "curl -X POST -H \"Content-Type: application/json\" -d '{\"msg_type\":\"interactive\",\"card\":{\"config\":{},\"i18n_elements\":{\"zh_cn\":[{\"tag\":\"column_set\",\"flex_mode\":\"none\",\"horizontal_spacing\":\"default\",\"background_style\":\"default\",\"columns\":[{\"tag\":\"column\",\"elements\":[{\"tag\":\"div\",\"text\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目${targetVersion}版本打包成功,请及时获取备份\",\"text_size\":\"normal\",\"text_align\":\"left\",\"text_color\":\"default\"},\"icon\":{\"tag\":\"standard_icon\",\"token\":\"announce_filled\",\"color\":\"grey\"}}],\"width\":\"weighted\",\"weight\":1}]}]},\"i18n_header\":{\"zh_cn\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"版本构建成功通知\"},\"subtitle\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目打包成功\"},\"template\":\"green\",\"ud_icon\":{\"tag\":\"standard_icon\",\"token\":\"msgcard-rectangle_outlined\"}}}}}}' https://open.feishu.cn/open-apis/bot/v2/hook/ecad4435-131f-4d49-ab2b-d12e95c9b245"
// } else if(env.branch == "dev" || env.branch == "test" || env.branch =~ /(^f_.*_dev$)|(^fix_.*_test$)/) {
// sh "curl -X POST -H \"Content-Type: application/json\" -d '{\"msg_type\":\"interactive\",\"card\":{\"config\":{},\"i18n_elements\":{\"zh_cn\":[{\"tag\":\"column_set\",\"flex_mode\":\"none\",\"horizontal_spacing\":\"default\",\"background_style\":\"default\",\"columns\":[{\"tag\":\"column\",\"elements\":[{\"tag\":\"div\",\"text\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目${targetVersion}版本打包成功且已部署到${env.branchConfig}环境,请及时查看验证\",\"text_size\":\"normal\",\"text_align\":\"left\",\"text_color\":\"default\"},\"icon\":{\"tag\":\"standard_icon\",\"token\":\"announce_filled\",\"color\":\"grey\"}}],\"width\":\"weighted\",\"weight\":1}]}]},\"i18n_header\":{\"zh_cn\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"版本构建成功通知\"},\"subtitle\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目部署成功\"},\"template\":\"green\",\"ud_icon\":{\"tag\":\"standard_icon\",\"token\":\"msgcard-rectangle_outlined\"}}}}}}' https://open.feishu.cn/open-apis/bot/v2/hook/ecad4435-131f-4d49-ab2b-d12e95c9b245"
// } else {
// sh "curl -X POST -H \"Content-Type: application/json\" -d '{\"msg_type\":\"interactive\",\"card\":{\"config\":{},\"i18n_elements\":{\"zh_cn\":[{\"tag\":\"column_set\",\"flex_mode\":\"none\",\"horizontal_spacing\":\"default\",\"background_style\":\"default\",\"columns\":[{\"tag\":\"column\",\"elements\":[{\"tag\":\"div\",\"text\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目${env.branch}分支构建成功,非有效分支无法进行远程部署,请及时检查\",\"text_size\":\"normal\",\"text_align\":\"left\",\"text_color\":\"default\"},\"icon\":{\"tag\":\"standard_icon\",\"token\":\"announce_filled\",\"color\":\"grey\"}}],\"width\":\"weighted\",\"weight\":1}]}]},\"i18n_header\":{\"zh_cn\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"版本构建成功通知\"},\"subtitle\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目构建成功\"},\"template\":\"green\",\"ud_icon\":{\"tag\":\"standard_icon\",\"token\":\"msgcard-rectangle_outlined\"}}}}}}' https://open.feishu.cn/open-apis/bot/v2/hook/ecad4435-131f-4d49-ab2b-d12e95c9b245"
// }
// }
// }
// failure {
// sh "curl -X POST -H \"Content-Type: application/json\" -d '{\"msg_type\":\"interactive\",\"card\":{\"config\":{},\"i18n_elements\":{\"zh_cn\":[{\"tag\":\"column_set\",\"flex_mode\":\"none\",\"horizontal_spacing\":\"default\",\"background_style\":\"default\",\"columns\":[{\"tag\":\"column\",\"elements\":[{\"tag\":\"div\",\"text\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目${env.branch}分支构建失败,请及时查看失败原因并修复\",\"text_size\":\"normal\",\"text_align\":\"left\",\"text_color\":\"default\"},\"icon\":{\"tag\":\"standard_icon\",\"token\":\"announce_filled\",\"color\":\"grey\"}}],\"width\":\"weighted\",\"weight\":1}]}]},\"i18n_header\":{\"zh_cn\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"项目构建失败通知\"},\"subtitle\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目构建失败\"},\"template\":\"red\",\"ud_icon\":{\"tag\":\"standard_icon\",\"token\":\"close_outlined\"}}}}}}' https://open.feishu.cn/open-apis/bot/v2/hook/ecad4435-131f-4d49-ab2b-d12e95c9b245"
// }
// aborted {
// sh "curl -X POST -H \"Content-Type: application/json\" -d '{\"msg_type\":\"interactive\",\"card\":{\"config\":{},\"i18n_elements\":{\"zh_cn\":[{\"tag\":\"column_set\",\"flex_mode\":\"none\",\"horizontal_spacing\":\"default\",\"background_style\":\"default\",\"columns\":[{\"tag\":\"column\",\"elements\":[{\"tag\":\"div\",\"text\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目${env.branch}分支构建未成功,请及时查看失败原因并修复\",\"text_size\":\"normal\",\"text_align\":\"left\",\"text_color\":\"default\"},\"icon\":{\"tag\":\"standard_icon\",\"token\":\"announce_filled\",\"color\":\"grey\"}}],\"width\":\"weighted\",\"weight\":1}]}]},\"i18n_header\":{\"zh_cn\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"项目构建未成功通知\"},\"subtitle\":{\"tag\":\"plain_text\",\"content\":\"${projectName}项目构建未成功\"},\"template\":\"orange\",\"ud_icon\":{\"tag\":\"standard_icon\",\"token\":\"warning_outlined\"}}}}}}' https://open.feishu.cn/open-apis/bot/v2/hook/ecad4435-131f-4d49-ab2b-d12e95c9b245"
// }
cleanup {
sh "echo '清理无用空镜像'"
sh "docker image prune -f"
sh "var=\$(docker ps -a -q --filter \"status=exited\");if [ -n \"\$var\" ];then docker rm \$var; fi"
cleanWs()
sh '''
echo "开始清理已停止容器......"
# 1. 清理已停止的容器
# -r 参数防止 xargs 在输入为空时报错
docker ps -a -q --filter "status=exited" | xargs -r docker rm
echo "清理已停止容器完成......"
echo "清理无用空镜像......"
# 2. 清理无用的空镜像 (dangling images)
docker image prune -f
echo "无用空镜像清理完成"
'''
deleteDir()
}
}

116
components.d.ts vendored
View File

@@ -5,21 +5,29 @@
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import { GlobalComponents } from 'vue'
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
CardItem: typeof import('./src/components/cardItem/index.vue')['default']
AutoTooltip: typeof import('./src/components/autoTooltip/index.vue')['default']
CardItem: typeof import('./src/components/cardManager/cardItem.vue')['default']
CardManager: typeof import('./src/components/CardManager/index.vue')['default']
CardModule: typeof import('./src/components/cardModule/index.vue')['default']
CollapseHeader: typeof import('./src/components/collapseHeader/index.vue')['default']
Comment: typeof import('./src/components/comment/index.vue')['default']
CommonFilter: typeof import('./src/components/commonFilter/index.vue')['default']
DynamicSvgIcon: typeof import('./src/components/dynamicSvgIcon/index.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxButton: typeof import('element-plus/es')['ElCheckboxButton']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePick: typeof import('element-plus/es')['ElDatePick']
@@ -32,6 +40,7 @@ declare module 'vue' {
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
@@ -40,12 +49,14 @@ declare module 'vue' {
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMain: typeof import('element-plus/es')['ElMain']
ElMention: typeof import('element-plus/es')['ElMention']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
@@ -53,8 +64,10 @@ declare module 'vue' {
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSkeletonItem: typeof import('element-plus/es')['ElSkeletonItem']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTableV2: typeof import('element-plus/es')['ElTableV2']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
@@ -65,18 +78,119 @@ declare module 'vue' {
EmojiPicker: typeof import('./src/components/comment/emojiPicker.vue')['default']
GlobaIcon: typeof import('./src/components/globaIcon/index.vue')['default']
GlobalIcon: typeof import('./src/components/GlobalIcon/index.vue')['default']
MemberSelector: typeof import('./src/components/memberSelector/index.vue')['default']
NameAvatar: typeof import('./src/components/nameAvatar/index.vue')['default']
NodeFlow: typeof import('./src/components/nodeFlow/index.vue')['default']
OverflowTabs: typeof import('./src/components/overflowTabs/index.vue')['default']
PageForm: typeof import('./src/components/pageForm/index.vue')['default']
PopoverMenu: typeof import('./src/components/popoverMenu/index.vue')['default']
ProTable: typeof import('./src/components/proTable/index.vue')['default']
ProTablev2: typeof import('./src/components/proTable/proTablev2.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SopNode: typeof import('./src/components/nodeFlow/sopNode.vue')['default']
StageBreadcrumbs: typeof import('./src/components/stageBreadcrumbs/index.vue')['default']
StandardMenu: typeof import('./src/components/standardMenu/index.vue')['default']
StandMenu: typeof import('./src/components/standMenu/index.vue')['default']
UserSelector: typeof import('./src/components/userSelector/index.vue')['default']
Viewswitcher: typeof import('./src/components/viewswitcher/index.vue')['default']
ViewSwitcher: typeof import('./src/components/viewSwitcher/index.vue')['default']
VirtualCardList: typeof import('./src/components/cardManager/VirtualCardList.vue')['default']
Xxx: typeof import('./src/components/comment/xxx.vue')['default']
Xxxx: typeof import('./src/components/xxxx/index.vue')['default']
}
export interface GlobalDirectives {
vInfiniteScroll: typeof import('element-plus/es')['ElInfiniteScroll']
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
// For TSX support
declare global {
const AutoTooltip: typeof import('./src/components/autoTooltip/index.vue')['default']
const CardItem: typeof import('./src/components/cardManager/cardItem.vue')['default']
const CardManager: typeof import('./src/components/CardManager/index.vue')['default']
const CardModule: typeof import('./src/components/cardModule/index.vue')['default']
const CollapseHeader: typeof import('./src/components/collapseHeader/index.vue')['default']
const Comment: typeof import('./src/components/comment/index.vue')['default']
const CommonFilter: typeof import('./src/components/commonFilter/index.vue')['default']
const DynamicSvgIcon: typeof import('./src/components/dynamicSvgIcon/index.vue')['default']
const ElAside: typeof import('element-plus/es')['ElAside']
const ElAvatar: typeof import('element-plus/es')['ElAvatar']
const ElBadge: typeof import('element-plus/es')['ElBadge']
const ElButton: typeof import('element-plus/es')['ElButton']
const ElCard: typeof import('element-plus/es')['ElCard']
const ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
const ElCheckboxButton: typeof import('element-plus/es')['ElCheckboxButton']
const ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
const ElCol: typeof import('element-plus/es')['ElCol']
const ElContainer: typeof import('element-plus/es')['ElContainer']
const ElDatePick: typeof import('element-plus/es')['ElDatePick']
const ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
const ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
const ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
const ElDialog: typeof import('element-plus/es')['ElDialog']
const ElDivider: typeof import('element-plus/es')['ElDivider']
const ElDrawer: typeof import('element-plus/es')['ElDrawer']
const ElDropdown: typeof import('element-plus/es')['ElDropdown']
const ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
const ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
const ElEmpty: typeof import('element-plus/es')['ElEmpty']
const ElForm: typeof import('element-plus/es')['ElForm']
const ElFormItem: typeof import('element-plus/es')['ElFormItem']
const ElHeader: typeof import('element-plus/es')['ElHeader']
const ElIcon: typeof import('element-plus/es')['ElIcon']
const ElInput: typeof import('element-plus/es')['ElInput']
const ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
const ElLink: typeof import('element-plus/es')['ElLink']
const ElMain: typeof import('element-plus/es')['ElMain']
const ElMention: typeof import('element-plus/es')['ElMention']
const ElMenu: typeof import('element-plus/es')['ElMenu']
const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
const ElOption: typeof import('element-plus/es')['ElOption']
const ElPagination: typeof import('element-plus/es')['ElPagination']
const ElPopover: typeof import('element-plus/es')['ElPopover']
const ElRadio: typeof import('element-plus/es')['ElRadio']
const ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
const ElRow: typeof import('element-plus/es')['ElRow']
const ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
const ElSelect: typeof import('element-plus/es')['ElSelect']
const ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
const ElSkeletonItem: typeof import('element-plus/es')['ElSkeletonItem']
const ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
const ElSwitch: typeof import('element-plus/es')['ElSwitch']
const ElTable: typeof import('element-plus/es')['ElTable']
const ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
const ElTableV2: typeof import('element-plus/es')['ElTableV2']
const ElTabPane: typeof import('element-plus/es')['ElTabPane']
const ElTabs: typeof import('element-plus/es')['ElTabs']
const ElTag: typeof import('element-plus/es')['ElTag']
const ElTimeline: typeof import('element-plus/es')['ElTimeline']
const ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
const ElTooltip: typeof import('element-plus/es')['ElTooltip']
const ElTree: typeof import('element-plus/es')['ElTree']
const EmojiPicker: typeof import('./src/components/comment/emojiPicker.vue')['default']
const GlobaIcon: typeof import('./src/components/globaIcon/index.vue')['default']
const GlobalIcon: typeof import('./src/components/GlobalIcon/index.vue')['default']
const MemberSelector: typeof import('./src/components/memberSelector/index.vue')['default']
const NameAvatar: typeof import('./src/components/nameAvatar/index.vue')['default']
const NodeFlow: typeof import('./src/components/nodeFlow/index.vue')['default']
const OverflowTabs: typeof import('./src/components/overflowTabs/index.vue')['default']
const PageForm: typeof import('./src/components/pageForm/index.vue')['default']
const PopoverMenu: typeof import('./src/components/popoverMenu/index.vue')['default']
const ProTable: typeof import('./src/components/proTable/index.vue')['default']
const ProTablev2: typeof import('./src/components/proTable/proTablev2.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
const SopNode: typeof import('./src/components/nodeFlow/sopNode.vue')['default']
const StageBreadcrumbs: typeof import('./src/components/stageBreadcrumbs/index.vue')['default']
const StandardMenu: typeof import('./src/components/standardMenu/index.vue')['default']
const StandMenu: typeof import('./src/components/standMenu/index.vue')['default']
const UserSelector: typeof import('./src/components/userSelector/index.vue')['default']
const Viewswitcher: typeof import('./src/components/viewswitcher/index.vue')['default']
const ViewSwitcher: typeof import('./src/components/viewSwitcher/index.vue')['default']
const VirtualCardList: typeof import('./src/components/cardManager/VirtualCardList.vue')['default']
const Xxx: typeof import('./src/components/comment/xxx.vue')['default']
const Xxxx: typeof import('./src/components/xxxx/index.vue')['default']
}

View File

@@ -4,7 +4,7 @@
<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>视界</title>
<title>服链</title>
</head>
<body>
<div id="app"></div>

View File

@@ -6,13 +6,19 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"test": "vitest",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@micro-zoe/micro-app": "^1.0.0-rc.28",
"@vitejs/plugin-vue-jsx": "^5.1.3",
"@vue-flow/core": "^1.48.1",
"@vue/test-utils": "^2.4.6",
"element-plus": "^2.13.0",
"happy-dom": "^20.1.0",
"pinia": "^3.0.4",
"vitest": "^4.0.17",
"vue": "^3.5.24",
"vue-i18n": "^11.2.7",
"vue-router": "^4.6.4"
@@ -23,6 +29,7 @@
"@vue/tsconfig": "^0.8.1",
"axios": "^1.13.2",
"dayjs": "^1.11.19",
"lodash-es": "^4.17.22",
"sass": "^1.97.1",
"typescript": "~5.9.3",
"unicode-emoji-json": "^0.8.0",

1207
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,11 @@ export const getRouteMenus = () => {
return request.get('/auth/v1/backend/menu');
};
// 获取当前用户信息
export const getUserInfo = () => {
return request.get('/auth/v1/my/info');
};
// 登录接口
export const login = (data: { username: string; password: string }) => {
return request.post('/auth/oauth2/token', data,{
@@ -12,4 +17,23 @@ export const login = (data: { username: string; password: string }) => {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
};
// 获取字典映射
export const getDictMap = (ids:string) => {
return request.get(`/auth/v1/dict/type/${ids}`);
};
// 获取二级菜单数据
export const getDictMapLevel = (key:string,parentId:string) => {
return request.get(`/auth/v1/dict/type/${key}/${parentId}`);
};
/**员工公用接口*/
// 根据员工关键字查询员工
export const getEmployeeList = (params: any) => {
return request.get('/auth/v1/employee', params);
};

View File

@@ -0,0 +1,41 @@
import request from '@/request';
type commentProps = {
moduleId:string;
nodeId:string;
instanceId:string;
commentId:string;
pageNo:number;
pageSize:number;
}
interface addCommentProps {
moduleId:string;
nodeId:string;
instanceId:string;
content:string;
mentions:Record<string,any>[]
[key:string]:any;
}
// 获取评论
export const getComment = (data: commentProps) => {
return request.post('/communicate/v1/comment/list', data);
}
// 添加评论-回复评论
export const addReplyComment = (data: addCommentProps) => {
return request.post('/communicate/v1/comment/add', data)
};
// 删除评论
export const deleteComment = (id: string) => {
return request.delete(`/communicate/v1/comment/del/${id}`);
}
// 根据员工关键字查询员工信息
export const getEmployeeByKeyword = (params: Record<string, any>) => {
return request.get(`/auth/v1/employee`,params);
}

View File

@@ -0,0 +1,69 @@
import request from '@/request';
type paramsProps = {
name:string;
domain:string;
}
type wxWorkProps = {
corpId:string;
agentId:number;
secret:string;
token:string;
encodingAesKey:string;
}
interface addDataProps{
name:string;
wxWork:wxWorkProps;
}
interface defaultProps{
keyword?:string;
pageNo:number;
pageSize:number;
}
// 查询企业
export const getEnterprise = (params: paramsProps) =>{
return request.get('/auth/v1/backend/enterprise', params);
}
// 添加企业
export const addEnterprise = (data: addDataProps) => {
return request.post('/auth/v1/backend/enterprise', data);
}
// 启用企业
export const enableEnterprise = (id:string) => {
return request.post(`/auth/v1/backend/enterprise/${id}/enable`);
}
// 禁用企业
export const disableEnterprise = (id:string) => {
return request.post(`/auth/v1/backend/enterprise/${id}/disable`);
}
// 加载企业机构
export const getEnterpriseOrg = (parentId:number) => {
return request.get(`/auth/v1/backend/enterprise/institution/${parentId}`);
}
// 加载员工详情
export const getEnterpriseUser = (employeeId:number) => {
return request.get(`/auth/v1/backend/enterprise/employee/${employeeId}`);
}
// 企业详情
export const getEnterpriseDetail = () => {
return request.get(`/auth/v1/backend/enterprise/detail`);
}
// 加载部门详情
export const getEnterpriseOrgDetail = (departmentId:string) => {
return request.get(`/auth/v1/backend/enterprise/department/${departmentId}`);
}
// 获取企业信息职位
export const getEnterprisePosition = (params:defaultProps) => {
return request.get(`/auth/v1/backend/enterprise/position`,params);
}

View File

@@ -0,0 +1,64 @@
import request from '@/request';
// 查询角色列表
export const getRoleList = (params: any) => {
return request.get('/auth/v1/backend/role', params);
}
// 获取角色详情
export const getRoleDetail = (id: string) => {
return request.get(`/auth/v1/backend/role/${id}`);
}
// 更新角色
export const updateRole = (data: any) => {
return request.put('/auth/v1/backend/role', data);
}
// 新增角色
export const addRole = (data: any) => {
return request.post('/auth/v1/backend/role', data);
}
// 删除角色
export const deleteRole = (id: string) => {
return request.delete(`/auth/v1/backend/role/${id}`);
}
// 启用角色
export const enableRole = (id: string) => {
return request.put(`/auth/v1/backend/role/${id}/enable`);
}
// 禁用角色
export const disableRole = (id: string) => {
return request.put(`/auth/v1/backend/role/${id}/disable`);
}
// 复制权限
export const copyRolePermission = (roleId: string) => {
return request.post(`/auth/v1/backend/role/${roleId}/copy`);
}
// 批量保存角色
export const batchSaveRole = (roleId: string,data: number[]) => {
return request.post(`auth/v1/backend/role/${roleId}/members`, data);
}
/**------------------------角色权限相关---------------------------**/
// 保存角色权限
export const saveRolePermission = (data: any) => {
return request.post(`/auth/v1/backend/role/permissions/save`, data);
}
// 查询角色权限 (获取角色权限详情)
export const getRolePermission = (roleId: string) => {
return request.get(`/auth/v1/backend/role/${roleId}/permissions`);
}
// 获取角色成员列表
export const getRoleMemberList = (roleId: string) => {
return request.get(`/auth/v1/backend/role/${roleId}/members`);
}

View File

@@ -7,8 +7,10 @@
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const ElButton: typeof import('element-plus/es').ElButton
const ElMessage: typeof import('element-plus/es').ElMessage
const ElMessageBox: typeof import('element-plus/es').ElMessageBox
const ElTag: typeof import('element-plus/es').ElTag
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp

View File

@@ -0,0 +1,39 @@
<template>
<el-tooltip
:content="content"
:disabled="!isOverflow"
placement="top"
v-bind="$attrs"
>
<div
class="ellipsis-content"
@mouseenter="checkOverflow"
>
<slot>{{ content }}</slot>
</div>
</el-tooltip>
</template>
<script setup>
import { ref } from 'vue';
defineOptions({ name: 'AutoEllipsis' })
defineProps({
content: String
});
const isOverflow = ref(false);
const checkOverflow = (event) => {
const el = event.currentTarget;
isOverflow.value = el.scrollWidth > el.clientWidth;
};
</script>
<style scoped>
.ellipsis-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
</style>

View File

@@ -1,128 +0,0 @@
<template>
<div
class="mj-card-container"
:style="containerStyle"
>
<div
class="mj-card-standard"
v-for="(card,carIndex) in list"
:key="carIndex"
@click="cardItemClick(card, carIndex)"
>
<slot name="cardTip">
<div class="mj-card-standard-tip" :style="standardTopStyle"></div>
</slot>
<div class="mj-card-standard-content">
<slot name="content"></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'CardItem' });
const { list = [], maxColumns = 4, standardTopStyle } = defineProps<{
list: any[]
maxColumns?: number // 每行最大展示数量,默认 4
standardTopStyle?: string | Record<string, any> // 外部传入的顶部样式
}>()
// 向外抛出的事件
const emits = defineEmits<{
/**
* 卡片点击事件
* @param e 事件名
* @param payload 点击的卡片数据和索引
*/
(e: 'card-click', payload: { item: any; index: number }): void
}>()
// 根据屏幕宽度和最大列数计算实际列数
const screenWidth = ref(window.innerWidth)
const containerStyle = computed(() => {
let cols = maxColumns
// 响应式调整
if (screenWidth.value <= 640) {
cols = 1
} else if (screenWidth.value <= 900) {
cols = Math.min(maxColumns, 2)
} else if (screenWidth.value <= 1200) {
cols = Math.min(maxColumns, 3)
}
return {
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`
}
})
// 卡片点击事件,向外抛出当前卡片数据和索引
const cardItemClick = (item: any, index: number) => {
emits('card-click', { item, index })
}
// 监听窗口大小变化
const handleResize = () => {
screenWidth.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>
<style lang="scss" scoped>
.mj-card-container {
display: grid;
gap: 16px;
width: 100%;
.mj-card-standard {
--radius: 16px;
--padding-standard: 20px;
--border-color: #e5e7eb;
--background-color: #fff;
--tw-shadow: 0 8px 12px #0000001a, 0 0px 12px #0000001a;
background-color: var(--background-color);
min-height: 140px;
width: 100%;
box-sizing: border-box;
border-radius: var(--radius);
border: 1px solid var(--border-color);
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: var(--tw-shadow);
}
.mj-card-standard-tip {
--height: 8px;
height: var(--height);
border-top-left-radius: inherit;
border-top-right-radius: inherit;
background-color: red;
}
.mj-card-standard-content {
padding: var(--padding-standard);
}
}
}
// 平板:调整间距
@media (max-width: 900px) {
.mj-card-container {
gap: 14px;
}
}
// 移动端:调整间距
@media (max-width: 640px) {
.mj-card-container {
gap: 12px;
}
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div class="mj-grid-container">
<div class="mj-card-item" @click="$emit('card-click', item)">
<slot name="card-prefix" :item="item">
</slot>
<div class="mj-card-standard-content">
<slot name="content" :item="item"></slot>
</div>
<div v-if="$slots.actions" class="mj-card-actions">
<slot name="actions" :item="item"></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "CardItem" });
// 接收单条数据 item 而不是 list
interface Props {
item: any;
standardTopStyle?: string | Record<string, any>;
}
const props = defineProps<Props>();
const emits = defineEmits<{
(e: "card-click", item: any): void;
}>();
</script>
<style lang="scss" scoped>
.mj-grid-container {
.mj-card-item {
--radius: 12px;
--primary-color: #409eff;
--border-color: #e4e7ed;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: var(--radius);
border: 1px solid var(--border-color);
overflow: hidden;
cursor: pointer;
// 丝滑动画过渡
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
box-shadow: 0 12px 24px rgba(64, 158, 255, 0.12);
}
.mj-card-standard-tip {
height: 8px;
background-color: var(--primary-color);
}
.mj-card-standard-content {
padding: 16px;
flex: 1;
}
.mj-card-actions {
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
}
// 封面图内部图片的平滑过渡
:deep(img) {
transition: transform 0.5s ease;
}
}
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<div class="card-manager-viewport" ref="viewportRef" @scroll.passive="handleScroll">
<div :style="{ height: `${paddingTop}px` }"></div>
<div class="mj-card-container">
<template v-for="(item, index) in visibleList" :key="item.id || index">
<slot :item="item" :openMenu="openMenu"></slot>
</template>
</div>
<div :style="{ height: `${paddingBottom}px` }"></div>
<div ref="loadMoreRef" class="load-more-trigger">
<el-icon v-if="loading" class="is-loading" :size="26"><Loading /></el-icon>
<span v-else-if="finished && list.length > 0">没有更多数据了</span>
</div>
<ActionMenu
:visible="menuVisible"
:style="menuStyle"
:data="activeData"
:bodyStyle="bodyStyle"
@close="closeMenu"
@action="(type, data) => $emit('on-action', type, data)"
/>
</div>
</template>
<script setup lang="ts" generic="T extends { id: string | number }">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { Loading } from '@element-plus/icons-vue';
import { useUniversalPopover } from "@/hooks/useActionMenu";
import ActionMenu from "@/components/popoverMenu/index.vue";
const props = defineProps<{
fetchApi: (params: any) => Promise<any>; // 外部传入请求方法
pageSize?: number; // 分页大小
itemHeight: number; // 必填:单行高度(含gap)
columnCount: number; // 必填:当前列数
extraParams?: Record<string, any>; // 外部搜索参数
}>();
// 菜单额外的样式
const bodyStyle = {
backgroundColor:'#fff',
boxShadow: '0 0px 10px rgba(0, 0, 0, 0.05)',
borderRadius: '6px',
border:'1px solid #EEF1F6'
}
const emit = defineEmits(['on-action']);
// --- 状态管理 ---
const list = ref<T[]>([]);
const loading = ref(false);
const finished = ref(false);
const viewportRef = ref<HTMLElement | null>(null);
const scrollTop = ref(0);
let pageNo = 1;
// --- 弹出菜单 Hook ---
const { visible: menuVisible, activeData, menuStyle, openMenu, closeMenu } = useUniversalPopover<T>();
// --- 1. 请求数据逻辑 ---
const fetchData = async () => {
if (loading.value || finished.value) return;
loading.value = true;
try {
const params = {
pageNo,
pageSize: props.pageSize || 20,
...props.extraParams
};
const res = await props.fetchApi(params);
const records = res.records || [];
if (records.length === 0) {
finished.value = true;
} else {
list.value.push(...records);
if (records.length < (props.pageSize || 20)) finished.value = true;
pageNo++;
}
} finally {
loading.value = false;
}
};
// --- 2. 虚拟滚动计算 (裁剪可见区域) ---
const visibleList = computed(() => {
const rowHeight = props.itemHeight;
if (rowHeight <= 0) return list.value;
const startRow = Math.floor(scrollTop.value / rowHeight);
const viewportHeight = viewportRef.value?.clientHeight || 800;
const visibleRows = Math.ceil(viewportHeight / rowHeight);
// 缓冲区设为 2 行,保证滑动顺畅
const start = Math.max(0, (startRow - 2) * props.columnCount);
const end = (startRow + visibleRows + 2) * props.columnCount;
return list.value.slice(start, end);
});
const paddingTop = computed(() => {
const rowHeight = props.itemHeight;
const startRow = Math.floor(scrollTop.value / rowHeight);
return Math.max(0, startRow - 2) * rowHeight;
});
const paddingBottom = computed(() => {
const rowHeight = props.itemHeight;
const totalRows = Math.ceil(list.value.length / props.columnCount);
const startRow = Math.floor(scrollTop.value / rowHeight);
const viewportHeight = viewportRef.value?.clientHeight || 800;
const visibleRows = Math.ceil(viewportHeight / rowHeight);
const renderedRows = startRow + visibleRows + 2;
return Math.max(0, (totalRows - renderedRows) * rowHeight);
});
const handleScroll = () => {
if (viewportRef.value) scrollTop.value = viewportRef.value.scrollTop;
};
// --- 3. 无缝加载监听 (IntersectionObserver) ---
const loadMoreRef = ref(null);
let observer: IntersectionObserver | null = null;
onMounted(() => {
fetchData(); // 初始化加载
observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) fetchData();
}, { root: viewportRef.value, threshold: 0.1 });
if (loadMoreRef.value) observer.observe(loadMoreRef.value);
});
// --- 4. 监听外部参数变化 (搜索重置) ---
watch(() => props.extraParams, () => {
list.value = [];
pageNo = 1;
finished.value = false;
scrollTop.value = 0;
viewportRef.value?.scrollTo(0, 0);
fetchData();
}, { deep: true });
onUnmounted(() => observer?.disconnect());
// 暴露给外部用于增删改的接口
defineExpose({ list });
</script>
<style lang="scss" scoped>
.card-manager-viewport {
height: 100%; // 外部容器需给高度
overflow-y: auto;
position: relative;
.load-more-trigger {
padding: 20px;
text-align: center;
color: #909399;
}
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="mj-collapse-header">
<div class="header-left">
<div class="title">{{ title }}</div>
<slot name="extra"></slot>
</div>
<div class="header-right">
<slot name="headerRight"></slot>
</div>
</div>
</template>
<!-- 全局公用collapse表头组件 -->
<script setup lang="ts">
defineProps({
title: {
type: String,
default: "",
},
});
</script>
<style lang="scss" scoped>
$primary-blue: #0052d9;
$text-main: #1d2129;
.mj-collapse-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding-right: 8px;
.header-left {
display: flex;
align-items: center;
.title {
position: relative;
display: flex;
align-items: center;
padding-left: 12px;
margin: 0;
font-size: 15px;
font-weight: 600;
color: $text-main;
// 使用伪类实现蓝色装饰条
&::before {
content: "";
position: absolute;
left: 0;
width: 3px;
height: 14px;
background-color: $primary-blue;
border-radius: 2px;
}
}
}
}
</style>

View File

@@ -1,77 +1,112 @@
<template>
<div class="mj-filter-group">
<div :class="className ? className : 'mj-icon-container'">
<el-popover
ref="filterPopover"
trigger="click"
popper-class="filter-popper"
placement="bottom-end"
:teleported="true"
width="auto"
@hide="$emit('on-hide')"
>
<template #reference>
<div class="mj-icon-warp">
<div class="mj-icon-item" title="筛选">
<el-icon><Filter /></el-icon>
</div>
<slot name="filterLabel"></slot>
</div>
</template>
<div class="filter-container" @click.stop>
<slot></slot>
<div class="mj-icon-warp" @click.stop="openMenu($event, null)">
<div class="mj-icon-item" title="筛选">
<el-icon><Filter /></el-icon>
</div>
</el-popover>
<div class="mj-icon-item" title="下载" @click="$emit('download')" v-if="download">
<slot name="filterLabel"></slot>
</div>
<div
class="mj-icon-item"
title="下载"
@click="onDownload"
v-if="download"
>
<el-icon><Download /></el-icon>
</div>
</div>
<ActionMenu
:visible="visible"
:style="menuStyle"
:data="null"
@close="handleClose"
>
<template #default>
<div class="filter-container" @click.stop>
<slot></slot>
</div>
</template>
</ActionMenu>
</div>
</template>
<script setup>
import { Filter, Download } from "@element-plus/icons-vue";
const filterPopover = ref(null);
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Filter, Download } from '@element-plus/icons-vue';
import ActionMenu from "@/components/popoverMenu/index.vue";
import { useUniversalPopover } from "@/hooks/useActionMenu";
// 定义事件:重置、应用、下载
defineEmits(["download",'on-hide']);
const emit = defineEmits(["download", "on-hide"]);
defineProps({
download:{
type:Boolean,
default:true
const props = defineProps({
download: {
type: Boolean,
default: true,
},
className:{
type:String,
default:''
}
})
defineExpose({
close() {
filterPopover.value?.hide()
className: {
type: String,
default: "",
},
});
// 使用 Hook配置为右对齐
const { visible, menuStyle, openMenu, closeMenu } = useUniversalPopover({
placement: 'bottom-end',
offsetY: 10,
offsetX: 37
});
// 监听隐藏事件,模拟原 el-popover 的 @hide
watch(visible, (newVal) => {
if (!newVal) {
emit('on-hide');
}
});
const handleClose = () => {
closeMenu();
};
// 暴露关闭方法
defineExpose({
close: closeMenu,
});
const onDownload = () => {
// @ts-ignore (确保你项目中已按需引入 ElMessage)
ElMessage.warning("功能开发中...");
emit("download");
};
</script>
<style lang="scss" scoped>
// 保留你原本的所有样式逻辑
.mj-icon-container {
--radius: 4px;
--bg-color: #fff;
--border-color: transparent;
--shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
--hover-color: #f5f7fa;
display: inline-flex;
align-items: center;
padding: 2px 4px;
border-radius: 4px;
background-color: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
.mj-icon-warp{
border-radius: var(--radius);
background-color: var(--bg-color);
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
.mj-icon-warp {
display: flex;
align-items: center;
padding-right: 3px;
font-size: 12px;
color:#45556C;
color: #45556c;
cursor: pointer;
}
.mj-icon-item {
display: flex;
align-items: center;
@@ -84,21 +119,22 @@ defineExpose({
transition: all 0.2s;
border-radius: 4px;
&:hover {
background-color: #f5f7fa;
color: #409eff;
&:hover,
&:active {
background-color: var(--hover-color);
color: var(--el-color-primary);
}
}
}
.mj-icon-level-container{
.mj-icon-level-container {
@extend .mj-icon-container;
border-radius: 10px;
box-shadow: none;
border: 1px solid #E2E8F0;
border: 1px solid #e2e8f0;
padding: 0 4px;
.mj-icon-item{
.mj-icon-item {
width: 30px;
height: 30px;
}

View File

@@ -0,0 +1,71 @@
<template>
<i class="svg-icon" :style="iconStyle" v-html="processedContent" v-if="content"></i>
<i v-else class="svg-icon" :style="iconStyle">
<svg
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
>
<path v-for="(d, index) in paths" :key="index" :d="d" fill="currentColor" />
</svg>
</i>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps({
// 预设图标名称
name: { type: String, default: '' },
// 外部传递的完整 SVG 字符串
content: { type: String, default: '' },
size: { type: [Number, String], default: 16 },
color: { type: String, default: '' }
});
// 预设路径数据
const iconData: Record<string, string[]> = {
building: [
"M832 128H192a32 32 0 0 0-32 32v704a32 32 0 0 0 32 32h640a32 32 0 0 0 32-32V160a32 32 0 0 0-32-32zm-64 704H256V192h512v640z",
"M320 256h384v64H320zm0 160h384v64H320zm0 160h384v64H320zM448 704h128v128H448z"
],
flag: [
"M288 128a32 32 0 0 0-32 32v704a32 32 0 0 0 64 0V544h448l-64-160 64-160H320V160a32 32 0 0 0-32-32zM320 288h352l-32 80 32 80H320V288z"
]
};
// 处理外部传入的 SVG 字符串,确保它能继承颜色
const processedContent = computed(() => {
if (!props.content) return '';
// 简单处理:确保外部 SVG 内部使用 currentColor
return props.content.replace(/fill="[^"]*"/g, 'fill="currentColor"');
});
const paths = computed(() => iconData[props.name] || []);
const iconStyle = computed(() => ({
fontSize: typeof props.size === 'number' ? `${props.size}px` : props.size,
color: props.color || 'inherit',
width: '1em',
height: '1em',
display: 'inline-flex'
}));
</script>
<style scoped lang="scss">
.svg-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
vertical-align: middle;
transition: color 0.2s, fill 0.2s; // 添加平滑过渡
:deep(svg) {
width: 1em;
height: 1em;
fill: currentColor; // 默认使用父级 color
}
}
</style>

View File

@@ -0,0 +1,344 @@
<template>
<el-drawer
v-model="drawerVisible"
size="440px"
class="standard-ui-back-drawer"
:close-on-click-modal="false"
:close-on-press-escape="false"
destroy-on-close
:with-header="false"
modal-class="standard-overlay-dialog-flat"
>
<div class="customer-drawer-header">
<div class="title-row">
<span class="decorator"></span>
<span class="title">成员管理</span>
<el-icon class="close-icon" @click="drawerVisible = false"
><Close
/></el-icon>
</div>
<p class="sub-title">角色<span>超级管理员</span></p>
</div>
<div class="drawer-body-container">
<div class="search-bar">
<el-select
v-model="memberSearchQuery"
placeholder="按姓名搜索成员"
multiple
filterable
remote
:remote-method="remoteMethod"
:loading="remoteLoading"
collapse-tags
:teleported="false"
:fit-input-width="true"
value-key="id"
v-select-more="handleLoadMore"
@change="changeMember"
popper-class="standard-select-dropdown-height"
>
<el-option
v-for="item in selectOptions"
:key="item.id"
:value="item"
:label="item.name"
>
<div class="select-item">
<name-avatar
:size="26"
:name="item.name"
:src="item.avatar"
:bgColor="'var(--mj-avatar-bg)'"
:avatarTextColor="'var(--mj-text-color)'"
/>
<div class="select-item-name">
<AutoTooltip :content="item.name"/>
</div>
</div>
</el-option>
<el-option
v-if="loadMore"
disabled
class="mj-select-dropdown-loading"
value="loading"
>
<div class="status-container">
<el-icon v-if="loadMore" class="is-loading" :size="20"
><Loading
/></el-icon>
</div>
</el-option>
</el-select>
</div>
<el-scrollbar
height="calc(100vh - 220px)"
@end-reached="loadMoreList"
ref="listRef"
>
<div class="member-list">
<transition-group name="list-fade">
<div v-for="item in displayMemberList" :key="item.id" class="member-item">
<name-avatar
:size="30"
:name="item.name"
:src="item.avatar"
:bgColor="'var(--mj-avatar-bg)'"
:avatarTextColor="'var(--mj-text-color)'"
/>
<div class="member-info">
<div class="name">{{ item.name }}</div>
<div class="dept">
{{
(item.departments || []).map((dept) => dept.name).join(",")
}}
</div>
</div>
<div class="member-action">
<el-button link type="info" @click.stop="handleRemove(item.id)"
>移除</el-button
>
</div>
</div>
</transition-group>
</div>
</el-scrollbar>
</div>
<template #footer>
<div class="custom-flat-drawer-footer">
<div class="stats-info">
<span class="label">统计信息</span>
<span class="count"> {{ memberList.length }} 名成员</span>
</div>
<div class="actions">
<el-button link @click="cancelMember">取消</el-button>
<el-button type="primary" class="btn-confirm" @click="saveMember"
>确认保存变更</el-button
>
</div>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { Search } from "@element-plus/icons-vue";
import NameAvatar from "@/components/NameAvatar/index.vue";
import { getEmployeeList } from "@/api";
import { useSelectLoadMore } from "@/hooks/useSelectLoadMore";
import { useLocalManager } from "@/hooks/useLocalManager";
import AutoTooltip from "@/components/autoTooltip/index.vue";
const {
options,
remoteLoading,
loadMore,
noMore,
remoteMethod,
handleLoadMore,
} = useSelectLoadMore({
fetchApi: (p) => getEmployeeList({ ...p, kind: 1 }),
});
defineOptions({ name: "MemberSelector" });
// emit数据
const emit = defineEmits(["save-member"]);
const listRef = ref(null);
const drawerVisible = defineModel("visible", { type: Boolean });
// 搜索部门的数据
const memberSearchQuery = ref([]);
const memberList = defineModel<any[]>("dataList", { default: () => [] });
const {
displayData: displayMemberList,
loadMore: handleLoadMoreList,
noMore: noMoreLocal
} = useLocalManager(() => memberList.value, 20);
// 动态返回options数据
const selectOptions = computed(() => {
const remoteData = options.value || [];
const selectedData = memberList.value || [];
// 创建一个 Map 用于去重,以 id 为 key
const optionMap = new Map();
// 1. 先把远程搜索结果放进去
remoteData.forEach(item => optionMap.set(item.id, item));
// 2. 再把已选择的数据放进去
selectedData.forEach(item => {
if (!optionMap.has(item.id)) {
optionMap.set(item.id, item);
}
});
return Array.from(optionMap.values());
});
// 监听数据变化
watch(
() => memberList.value,
(newVal) => {
memberSearchQuery.value = [...newVal];
},
{ immediate: true, deep: true }
);
// 添加成员 (添加成员)
const changeMember = (value) => {
memberList.value = value;
nextTick(() => {
if (listRef.value) {
listRef.value.scrollTo({
top: listRef.value?.wrapRef.scrollHeight,
behavior: "smooth",
});
}
});
};
// 移除成员方法
const handleRemove = async (id) => {
memberList.value = memberList.value.filter((item) => item.id !== id);
};
// 前端加载更多 触底操作
const loadMoreList = () => {
handleLoadMoreList();
};
// 保存成员方法
const saveMember = async () => {
try {
await emit("save-member");
cancelMember();
} catch (error) {
console.log("add member error", error);
}
};
// 取消成员方法
const cancelMember = () => {
drawerVisible.value = false;
memberSearchQuery.value = [];
};
</script>
<style lang="scss" scoped>
.standard-ui-back-drawer {
.drawer-body-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.search-bar {
padding: 20px 24px;
display: flex;
gap: 12px;
background-color: #f8fafc;
:deep(.el-input) {
--el-input-border-radius: 2px;
}
}
.member-list {
padding: 0 14px 20px;
box-sizing: border-box;
.list-fade-enter-active,
.list-fade-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.list-fade-enter-from {
opacity: 0;
transform: translateY(-20px); // 从上方滑入
background-color: #ecf5ff; // 初始色可以亮一点
}
.list-fade-leave-to {
opacity: 0;
transform: translateX(30px); // 向侧面滑出
}
.member-item {
--mj-avatar-bg: #f2f3f5;
--mj-text-color: #abb0b8;
display: flex;
align-items: center;
padding: 10px 16px;
margin-bottom: 4px;
border-radius: 2px;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s;
&:hover {
--mj-avatar-bg: #d1e9ff;
--mj-text-color: #409eff;
background-color: #f8fafc;
border-color: #f5f5f5;
.member-action {
opacity: 1;
}
}
.member-info {
flex: 1;
margin-left: 12px;
.name {
font-size: 14px;
color: #303133;
font-weight: 500;
}
.dept {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
}
.member-action {
opacity: 0; // 默认隐藏高亮或hover显示
transition: opacity 0.2s;
}
}
}
.select-item {
--mj-avatar-bg: #f2f3f5;
--mj-text-color: #abb0b8;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
&:hover {
--mj-avatar-bg: #d1e9ff;
--mj-text-color: #409eff;
}
.select-item-name {
width: calc(100% - 34px);
}
}
.el-drawer__footer {
padding: 16px 24px;
.custom-flat-drawer-footer {
.stats-info {
display: flex;
flex-direction: column;
line-height: 1.4;
.label {
font-size: 12px;
color: #909399;
}
.count {
font-size: 14px;
color: #303133;
font-weight: bold;
}
}
}
}
}
</style>

View File

@@ -2,7 +2,12 @@
<el-avatar
:size="size"
:src="src"
:style="{ backgroundColor: !src ? bgColor : '' }"
:style="{
backgroundColor: !src ? bgColor : '',
'--avatar-text-size': fontSize,
'--avatar-text-color': avatarTextColor,
}"
v-bind="$attrs"
class="mj-name-avatar"
>
<span v-if="!src" class="avatar-text">{{ displayText }}</span>
@@ -10,31 +15,61 @@
</template>
<script setup>
import { computed } from 'vue';
defineOptions({name: 'NameAvatar'})
import { computed } from "vue";
defineOptions({ name: "NameAvatar" });
const props = defineProps({
name: { type: String, default: '' },
src: { type: String, default: '' },
size: { type: Number, default: 40 }
name: { type: String, default: "" },
src: { type: String, default: "" },
size: { type: Number, default: 40 },
bgColor: { type: String, default: "" },
avatarTextColor: { type: String, default: "" },
});
const displayText = computed(() => {
return props.name ? props.name.charAt(0) : '';
return props.name ? props.name.charAt(0) : "";
});
const fontSize = computed(() => {
return `${Math.max(props.size * 0.4, 12)}px`;
});
const bgColor = computed(() => {
if (!props.name) return '#409EFF';
if (!props.name) return "#409EFF";
if (props.bgColor) return props.bgColor;
let hash = 0;
for (let i = 0; i < props.name.length; i++) {
hash = props.name.charCodeAt(i) + ((hash << 5) - hash);
}
const colors = [
'#337ecc', '#409eff', '#53a8ff', '#79bbff', '#95d475',
'#eebe77', '#f89898', '#b37feb', '#ff85c0',
'#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2',
'#eb2f96', '#a0d911', '#fa8c16', '#e74c3c', '#9b59b6',
'#1abc9c', '#34495e', '#f39c12', '#e67e22', '#3498db',
'#9b59b6', '#2ecc71', '#f1c40f', '#d35400', '#7f8c8d'
"#337ecc",
"#409eff",
"#53a8ff",
"#79bbff",
"#95d475",
"#eebe77",
"#f89898",
"#b37feb",
"#ff85c0",
"#52c41a",
"#faad14",
"#f5222d",
"#722ed1",
"#13c2c2",
"#eb2f96",
"#a0d911",
"#fa8c16",
"#e74c3c",
"#9b59b6",
"#1abc9c",
"#34495e",
"#f39c12",
"#e67e22",
"#3498db",
"#9b59b6",
"#2ecc71",
"#f1c40f",
"#d35400",
"#7f8c8d",
];
return colors[Math.abs(hash) % colors.length];
});
@@ -42,16 +77,16 @@ const bgColor = computed(() => {
<style scoped lang="scss">
.mj-name-avatar {
--el-avatar-bg-color:transparent;
--el-avatar-bg-color: transparent;
user-select: none;
font-weight: 500;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
.avatar-text {
color: var(--el-avatar-text-color);
font-size: 16px;
color: var(--avatar-text-color);
font-size: var(--avatar-text-size);
letter-spacing: -0.5px;
line-height: 1;
}
}
</style>
</style>

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import { ref } from "vue";
import { VueFlow, useVueFlow, getRectOfNodes } from "@vue-flow/core";
import sopNode from "./sopNode.vue";
const { updateNode, setViewport, nodes: flowNodes } = useVueFlow();
const nodes = ref([
{
id: "1",
type: "my-custom", // 对应插槽名 #node-my-custom
position: { x: 0, y: 0 },
label: "完成项目文档",
data: { title: "开发任务", isFinished: true, value: "", isActive: false }, // 传入自定义数据
},
{
id: "2",
type: "my-custom",
position: { x: 160 + 64, y: 0 },
label: "代码上线评审",
selected: true,
data: {
title: "代码上线评审",
isFinished: false,
value: "",
isActive: true,
},
},
{
id: "3",
type: "my-custom",
position: { x: 320 + 128, y: 0 },
label: "代码发布",
data: { title: "代码发布", value: "", isFinished: false, isActive: false },
},
{
id: "3-top",
type: "my-custom",
label: "任务完成",
position: { x: 448 + 192, y: -100 },
data: { title: "任务完成" },
}, // 向上偏
{
id: "3-bottom",
type: "my-custom",
label: "任务未完成",
position: { x: 448 + 192, y: 100 },
data: { title: "任务未完成" },
}, // 向下偏
]);
const edges = ref([
{ id: "e1-2", source: "1", target: "2", },
{ id: "e2-3", source: "2", target: "3", },
{ id: "e3-3t", source: "3", target: "3-top", },
{ id: "e3-3b", source: "3", target: "3-bottom", },
]);
const PADDING = 20;
const canvasRect = computed(() => {
// 过滤掉还没有尺寸信息的节点
const validNodes = flowNodes.value.filter((n) => n.dimensions.width > 0);
if (validNodes.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
return getRectOfNodes(validNodes);
});
const translateExtent = computed(() => {
const rect = canvasRect.value;
if (rect.width === 0)
return [
[0, 0],
[0, 0],
];
const result = [
[rect.x - PADDING, rect.y - PADDING],
[rect.x + rect.width + PADDING, rect.y + rect.height + PADDING],
];
return result;
});
const canvasStyle = computed(() => {
const bounds = canvasRect.value;
return {
// 宽度 = 节点矩形宽度 + 左右边距
width: `${bounds.width + PADDING * 2}px`,
// 高度 = 节点矩形高度 + 上下边距
height: `${bounds.height + PADDING * 2}px`,
position: "relative",
};
});
// 新增节点
const addNode = () => {
const HORIZONTAL_GAP = 40; // 缩短间距,让主干紧贴汇合点
const MAX_WIDTH = 160;
const VERTICAL_GAP = 64;
// 1. 获取所有待链接分支中,最右侧的边界
const pendingBranchNodes = nodes.value.filter(node => {
const isBranch = node.id.includes('top') || node.id.includes('bottom');
const isAlreadyLinked = edges.value.some(edge => edge.source === node.id);
return isBranch && !isAlreadyLinked;
});
let nextX = 0;
if (pendingBranchNodes.length > 0) {
// 如果有分支待汇合,新主干 X = 分支最右侧位置 + 小间距
// 注意:这里最好加上节点自身的宽度(假设是 160
const rightmostX = Math.max(...pendingBranchNodes.map(n => n.position.x));
nextX = rightmostX + MAX_WIDTH + HORIZONTAL_GAP;
} else {
// 如果没有分支,按正常主干逻辑追加
const mainNodes = nodes.value.filter(n => !n.id.includes('-'));
const lastMain = mainNodes[mainNodes.length - 1];
nextX = (lastMain?.position.x || 0) + MAX_WIDTH + VERTICAL_GAP;
}
const newId = (nodes.value.reduce((max, n) => Math.max(max, parseInt(n.id) || 0), 0) + 1).toString();
// 2. 创建新节点Y 轴保持 0主干中轴
const newNode = {
id: newId,
type: "my-custom",
position: { x: nextX, y: 0 },
label: `任务 ${newId}`,
data: { title: `任务 ${newId}`, isFinished: false, isActive: false },
};
// 3. 连线逻辑不变,但确保 type 是 smoothstep
const newEdges = [];
// 链接分支到汇合点
pendingBranchNodes.forEach(branch => {
newEdges.push({
id: `e${branch.id}-${newId}`,
source: branch.id,
target: newId,
type: "smoothstep",
// 调整 pathOptions 让折线更陡峭/紧凑,视觉上更像在“链接点处”
pathOptions: { borderRadius: 10, offset: 10 }
});
});
// 主干相连
const mainNodes = nodes.value.filter(n => !n.id.includes('-'));
if (mainNodes.length > 0) {
const prevMain = mainNodes[mainNodes.length - 1];
newEdges.push({
id: `e${prevMain.id}-${newId}`,
source: prevMain.id,
target: newId,
type: "smoothstep"
});
}
nodes.value.push(newNode);
edges.value.push(...newEdges);
};
const handleNodeClick = (clickedNodeId) => {
// 1. 先判断点击的是否是已禁用的节点,如果是则直接跳过
const targetNode = nodes.value.find((n) => n.id === clickedNodeId);
if (targetNode?.data?.isFinished) return;
// 2. 一次性映射出所有节点的新状态
nodes.value = nodes.value.map((node) => {
return {
...node,
// 只有点击的那个变 true其他所有节点强制变 false
data: {
...node.data,
isActive: node.id === clickedNodeId,
},
// 顺便同步官方的选中状态,确保视觉和逻辑统一
selected: node.id === clickedNodeId,
};
});
};
// 监听 rect 的变化(当节点增减或尺寸测量完成时触发)
watch(
() => canvasRect.value,
async (rect) => {
console.log("rect", -rect.x + PADDING, rect.width);
if (rect.width > 0) {
await nextTick();
setViewport({
x: -rect.x + PADDING,
y: -rect.y + PADDING,
zoom: 1,
});
}
},
{ deep: true }
);
// 初始化setViewport
const onInit = (instance) => {
const validNodes = flowNodes.value.filter((n) => n.dimensions.width > 0);
if (validNodes.length > 0) {
const rect = canvasRect.value;
setViewport({
x: -rect.x + PADDING,
y: -rect.y + PADDING,
zoom: 1,
});
}
};
</script>
<template>
<el-button type="primary" @click="addNode">添加节点</el-button>
<div class="scroll-container">
<el-scrollbar>
<div :style="canvasStyle">
<VueFlow
:nodes="nodes"
:edges="edges"
:fit-view-on-init="false"
:zoom-on-scroll="false"
:nodes-draggable="false"
:zoom-on-double-click="false"
:pan-on-drag="false"
:nodes-connectable="false"
:selection-key="null"
:pan-activation-action="null"
:zoom-on-pinch="false"
:min-zoom="1"
:max-zoom="1"
:translate-extent="translateExtent"
@pane-ready="onInit"
>
<template #node-my-custom="nodeProps">
<sopNode v-bind="nodeProps" @activate-node="handleNodeClick" />
</template>
</VueFlow>
</div>
</el-scrollbar>
</div>
</template>
<style>
/* 官方基础样式还是要引,不然无法拖拽和缩放 */
@import "@vue-flow/core/dist/style.css";
@import "@vue-flow/core/dist/theme-default.css";
</style>
<style lang="scss" scoped>
:deep(.vue-flow__edge-path) {
stroke: #e2e8f0;
stroke-width: 1.5;
}
</style>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { Handle, Position } from "@vue-flow/core";
// 接收数据
const props = defineProps(["id","data", "label"]);
const emit = defineEmits(['activateNode']);
const updateItem = () => {
// 已经完成就不能在点击了
if(props.data.isFinished){
return;
}
emit('activateNode', props.id);
};
</script>
<template>
<div class="custom-step-node" :class="{ 'is-finished': data.isFinished }" @click="updateItem">
<div class="node-capsule" :class="{ 'is-active': data.isActive }">
<Handle type="target" :position="Position.Left" class="hidden-handle" />
<span class="status-dot"></span>
<span class="node-label">{{ data.title }}</span>
<Handle type="source" :position="Position.Right" class="hidden-handle" />
</div>
<div class="node-input-area" @click.stop>
<div class="input-wrapper-container">
<div class="input-wrapper">
<el-input type="text" v-model="data.value" @click.stop></el-input>
</div>
<span class="percent-unit">%</span>
</div>
<div class="node-tooltips">产能赋权</div>
</div>
</div>
</template>
<style scoped lang="scss">
.custom-step-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px; /* 胶囊与输入框的间距 */
width: 120px;
cursor: pointer;
}
/* 胶囊样式 */
.node-capsule {
display: flex;
align-items: center;
justify-content: center;
padding: 6px 16px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 20px; /* 圆角胶囊 */
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
position: relative;
width: 100%;
white-space: nowrap;
transition: all 0.3s ease;
}
/* 激活状态(蓝框) */
.node-capsule.is-active {
border-color: #2563eb;
color: #2563eb;
box-shadow: 0 0 0 1px #2563eb;
}
/* 状态小圆点 */
.status-dot {
width: 6px;
height: 6px;
background-color: #d1d5db; /* 默认灰色 */
border-radius: 50%;
margin-right: 8px;
}
.is-active .status-dot {
background-color: #2563eb; /* 激活蓝色 */
}
.node-label {
font-size: 13px;
color: #4b5563;
}
.is-active .node-label {
color: #2563eb;
font-weight: 500;
}
/* 输入框区域 */
.node-input-area {
display: inline-flex;
flex-direction: column;
align-items: center;
.input-wrapper-container{
display: inline-flex;
align-items: center;
gap: 4px;
}
.node-tooltips{
color: #90a1b9;
font-size: 12px;
opacity: 0;
margin-top: 3px;
}
&:hover .node-tooltips{
opacity: 1;
}
}
.input-wrapper {
width: 60px;
:deep(.el-input__inner){
text-align: center;
font-weight: 600;
}
}
.percent-input {
width: 100%;
border: none;
text-align: center;
font-size: 14px;
font-weight: bold;
color: #1f2937;
outline: none;
}
.percent-unit {
font-size: 12px;
color: #9ca3af;
}
.is-finished {
filter: grayscale(1);
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
/* 隐藏连接点逻辑,但保持在胶囊中心高度 */
.hidden-handle {
width: 0;
height: 0;
border: none;
background-color: transparent;
pointer-events: none;
}
</style>

View File

@@ -1,14 +1,20 @@
<template>
<div class="tabs-outer-container" ref="containerRef" :style="{ height: height + 'px' }">
<div class="ghost-wrapper" ref="ghostRef">
<div v-for="item in items" :key="'ghost' + item[itemMap.id]" class="tab-item">
<span class="tab-text">{{ item[itemMap.label] }}</span>
</div>
</div>
<div class="tabs-wrapper">
<div
v-for="(item, index) in visibleItems"
:key="item.id"
:key="item[itemMap.id]"
class="tab-item"
:class="{ active: modelValue === item.id }"
@click="$emit('update:modelValue', item.id)"
:class="{ 'active': modelValue === item[itemMap.id] }"
@click="$emit('update:modelValue', item[itemMap.id])"
>
<span class="tab-text">{{ item.label }}</span>
<span class="tab-text">{{ item[itemMap.label] }}</span>
</div>
<el-dropdown
@@ -17,7 +23,7 @@
@command="handleCommand"
class="more-dropdown"
>
<div class="tab-item more-trigger">
<div class="tab-item more-trigger" :class="{ 'is-more-active': isHiddenActive }">
<span>更多</span>
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</div>
@@ -25,11 +31,12 @@
<el-dropdown-menu>
<el-dropdown-item
v-for="item in hiddenItems"
:key="item.id"
:command="item.id"
:key="item[itemMap.id]"
:command="item[itemMap.id]"
>
<span :class="{ 'is-active-item-overflow-tabs': modelValue === item.id }">{{ item.label }}</span>
<span :class="{ 'is-active-item-overflow-tabs': modelValue === item[itemMap.id] }">
{{ item[itemMap.label] }}
</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -46,50 +53,88 @@ import { ArrowDown } from "@element-plus/icons-vue";
const props = defineProps({
modelValue: [String, Number],
items: {
type: Array,
default: () => [],
},
height: {
type: Number,
default: 32,
},
items: { type: Array, default: () => [] },
itemMap: { type: Object, default: () => ({ id: 'id', label: 'label' }) },
height: { type: Number, default: 32 },
});
const emit = defineEmits(["update:modelValue"]);
const containerRef = ref(null);
const ghostRef = ref(null);
const splitIndex = ref(props.items.length);
const itemWidths = ref([]); // 缓存所有项的宽度
let timer = null;
const activeBarStyle = ref({
width: "0px",
left: "0px",
opacity: 0,
});
const activeBarStyle = ref({ width: "0px", left: "0px", opacity: 0 });
const visibleItems = computed(() => props.items.slice(0, splitIndex.value));
const hiddenItems = computed(() => props.items.slice(splitIndex.value));
const isHiddenActive = computed(() => {
return hiddenItems.value.some((item) => item.id === props.modelValue);
return hiddenItems.value.some((item) => item[props.itemMap.id] === props.modelValue);
});
// 获取所有 Tab 的初始宽度
const measureWidths = () => {
if (!ghostRef.value) return;
const nodes = ghostRef.value.querySelectorAll(".tab-item");
if (nodes.length === 0) return;
const widths = Array.from(nodes).map(node => node.getBoundingClientRect().width);
// 只有拿到有效宽度才更新,防止在某些极端情况下宽度全为 0 导致计算错误
if (widths.some(w => w > 0)) {
itemWidths.value = widths;
}
};
const calculateLayout = () => {
if (!containerRef.value) return;
// 如果没有宽度数据,先测一遍
if (itemWidths.value.length === 0) {
measureWidths();
}
const containerWidth = containerRef.value.offsetWidth;
// 如果容器本身没宽度(比如在隐藏的弹窗里),直接返回
if (containerWidth <= 0) return;
const moreBtnWidth = 80;
let currentWidth = 0;
let newSplitIndex = props.items.length;
for (let i = 0; i < itemWidths.value.length; i++) {
const w = itemWidths.value[i];
const fullWidth = w;
if (currentWidth + fullWidth > containerWidth) {
newSplitIndex = i;
// 预留更多按钮位置
while (newSplitIndex > 0 && currentWidth + moreBtnWidth > containerWidth) {
newSplitIndex--;
currentWidth -= itemWidths.value[newSplitIndex];
}
break;
}
currentWidth += fullWidth;
}
splitIndex.value = newSplitIndex;
};
const updateActiveBar = async () => {
await nextTick();
if (!containerRef.value) return;
// 1. 检查激活项是否在可见区域
const activeIndex = visibleItems.value.findIndex((item) => item.id === props.modelValue);
const activeIndex = visibleItems.value.findIndex(item => item[props.itemMap.id] === props.modelValue);
if (activeIndex >= 0) {
// 激活项在可见区域:计算位置并显示下划线
const tabItems = containerRef.value.querySelectorAll(".tab-item:not(.more-trigger)");
const tabItems = containerRef.value.querySelectorAll(".tabs-wrapper > .tab-item:not(.more-trigger)");
const activeElement = tabItems[activeIndex];
if (activeElement) {
const rect = activeElement.getBoundingClientRect();
const containerRect = containerRef.value.getBoundingClientRect();
activeBarStyle.value = {
width: `${rect.width * 0.6}px`,
left: `${rect.left - containerRect.left + rect.width * 0.2}px`,
@@ -98,66 +143,42 @@ const updateActiveBar = async () => {
return;
}
}
// 2. 如果在隐藏区域或未找到,将下划线宽度设为 0
activeBarStyle.value = {
...activeBarStyle.value,
width: "0px",
opacity: 0,
};
activeBarStyle.value.opacity = 0;
};
const calculateLayout = () => {
if (!containerRef.value) return;
const containerWidth = Math.floor(containerRef.value.getBoundingClientRect().width);
const itemsNodes = containerRef.value.querySelectorAll(".tab-item:not(.more-trigger)");
const moreBtnWidth = 90;
let currentWidth = 0;
let newSplitIndex = props.items.length;
for (let i = 0; i < itemsNodes.length; i++) {
currentWidth += Math.ceil(itemsNodes[i].getBoundingClientRect().width) + 20;
if (currentWidth + moreBtnWidth > containerWidth) {
newSplitIndex = i;
break;
}
}
if (splitIndex.value !== newSplitIndex) {
splitIndex.value = newSplitIndex;
}
};
const debouncedCalc = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
calculateLayout();
const handleResize = () => {
calculateLayout();
nextTick(() => {
updateActiveBar();
}, 100);
});
};
let resizeObserver = null;
onMounted(async () => {
await nextTick();
measureWidths();
calculateLayout();
updateActiveBar();
resizeObserver = new ResizeObserver(() => debouncedCalc());
resizeObserver = new ResizeObserver(() => {
handleResize();
});
if (containerRef.value) resizeObserver.observe(containerRef.value);
});
onUnmounted(() => {
if (resizeObserver) resizeObserver.disconnect();
if (timer) clearTimeout(timer);
resizeObserver?.disconnect();
clearTimeout(timer);
});
const handleCommand = (id) => emit("update:modelValue", id);
watch(() => props.modelValue, () => updateActiveBar());
watch(() => props.items, async () => {
splitIndex.value = props.items.length;
await nextTick();
measureWidths();
calculateLayout();
updateActiveBar();
}, { deep: true });
@@ -165,8 +186,21 @@ watch(() => props.items, async () => {
<style scoped lang="scss">
.tabs-outer-container {
--more-left-line:#f0f2f5;
--item-color:#9DA1B9;
--item-size:12px;
width: 100%;
overflow: hidden;
position: relative;
// 关键:测量层不可见且不占位,但必须渲染以获取宽度
.ghost-wrapper {
position: absolute;
top: -9999px;
left: -9999px;
visibility: hidden;
display: flex;
white-space: nowrap;
}
.tabs-wrapper {
display: flex;
@@ -174,6 +208,7 @@ watch(() => props.items, async () => {
height: 100%;
position: relative;
user-select: none;
width: 100%;
}
.tab-item {
@@ -182,39 +217,37 @@ watch(() => props.items, async () => {
align-items: center;
padding: 0 20px;
cursor: pointer;
color: #606266;
font-size: 14px;
position: relative;
color: var(--item-color);
font-size: var(--item-size);
white-space: nowrap;
flex-shrink: 0;
transition: color 0.2s ease;
&.active {
color: #409eff;
font-weight: 600;
color: var(--el-color-primary);
}
&.is-more-active {
color: var(--el-color-primary);
}
}
.more-trigger {
margin-left: auto;
border-left: 1px solid #f0f2f5;
// 选中更多中的数据时,仅文字和图标变蓝
&.is-more-active {
color: #409eff;
font-weight: 600;
}
border-left: 1px solid var( --more-left-line);
}
.active-bar {
position: absolute;
height: 2px;
background-color: #409eff;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s;
bottom: 0;
background-color: var(--el-color-primary);
transition: all 0.12s cubic-bezier(0.4, 0, 0.2, 1);
bottom: -1px;
pointer-events: none;
}
}
.is-active-item-overflow-tabs {
color: var(--el-color-primary);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<Teleport to="body">
<transition name="el-zoom-in-top">
<div
v-if="visible"
ref="menuRef"
v-click-outside="close"
class="mj-universal-menu"
:style="[style, customContainerStyle]"
>
<slot :data="data" :close="close">
<div class="default-menu-wrapper">
<div class="menu-item" @click="handleAction('edit')">
<el-icon><Edit /></el-icon>
<span>编辑</span>
</div>
<div class="menu-item danger" @click="handleAction('delete')">
<el-icon><Delete /></el-icon>
<span>删除</span>
</div>
</div>
</slot>
</div>
</transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, type CSSProperties } from "vue";
import { ClickOutside as vClickOutside } from "element-plus";
import { Edit, Delete } from "@element-plus/icons-vue";
import type { MenuPosition } from "./types";
// 扩展 Props 以适应不同场景
const props = defineProps<{
visible: boolean;
style: MenuPosition;
data: T | null;
// 新增允许外部传入额外的样式如宽度、padding
bodyStyle?: CSSProperties;
}>();
const emit = defineEmits<{
close: [];
action: [type: string, data: T];
}>();
// 合并样式
const customContainerStyle = computed(() => {
return props.bodyStyle || {};
});
const close = () => emit("close");
const handleAction = (type: string) => {
if (props.data) {
emit("action", type, props.data);
}
close();
};
</script>
<style scoped lang="scss">
.mj-universal-menu {
position: fixed;
z-index: 3000;
width: max-content;
min-width: 100px;
max-width: 95vw;
overflow: hidden; // 保证内部 hover 背景不溢出圆角
// 当没有插槽内容时使用的默认包装器
.default-menu-wrapper {
padding: 4px 0;
min-width: 120px;
}
// 菜单项基础样式
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
font-size: 14px;
color: #606266;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s ease; // 增加平滑过渡
&:hover {
background-color: #f5f7fa;
color: var(--el-color-primary);
}
// 危险操作样式(如删除)
&.danger {
color: #f56c6c;
&:hover {
background-color: #fef0f0;
color: #f56c6c; // 保持红色
}
}
// 如果菜单里有图标,可以统一控制图标大小
.el-icon {
font-size: 16px;
}
}
}
</style>

View File

@@ -1,13 +1,14 @@
<template>
<div class="pro-table-container">
<div ref="tableContainerRef" class="pro-table-container">
<el-table
:data="data"
v-bind="$attrs"
v-loading="innerLoading"
class="hover-action-table"
header-row-class-name="header-row-name"
:height="tableHeight"
>
<template v-for="(col, index) in columns" :key="col.prop">
<template v-for="(col, index) in columns" :key="index">
<el-table-column
v-if="!col.slot && col.prop !== 'actions'"
v-bind="col"
@@ -46,6 +47,7 @@
>
<el-button
v-bind="getButtonProps(btn)"
:disabled="getButtonDisabled(btn, scope.row)"
@click="handleButtonClick(btn, scope.row)"
>
{{
@@ -57,15 +59,9 @@
</span>
</template>
<!-- 如果按钮超过maxButtons显示下拉菜单 -->
<!-- 如果按钮超过maxButtons显示下拉菜单 (如果是移入移除展示按钮 不建议开启下拉菜单)-->
<el-dropdown
v-if="
col.actions.length >
(col.maxButtons || MAX_BUTTON_LENGTH) &&
hasDropdownPermission(
col.actions.slice(col.maxButtons || MAX_BUTTON_LENGTH)
)
"
v-if="showDropdown && col.actions.length > (col.maxButtons || MAX_BUTTON_LENGTH) && hasDropdownPermission(col.actions.slice(col.maxButtons || MAX_BUTTON_LENGTH))"
class="dropdown-menu-table"
trigger="hover"
>
@@ -86,6 +82,7 @@
<el-dropdown-item>
<el-button
v-bind="getButtonProps(btn)"
:disabled="getButtonDisabled(btn, scope.row)"
@click="handleButtonClick(btn, scope.row)"
>
{{
@@ -108,9 +105,9 @@
</template>
</el-table>
<div class="pro-table-footer">
<div class="pro-table-footer" v-if="pagination">
<slot name="footer">
<div class="mj-footer-content" v-if="pagination">
<div class="mj-footer-content">
<div class="footer-left">
<span class="total-text">共 {{ total || data.length }} 个条目</span>
</div>
@@ -133,7 +130,7 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { ArrowDown } from "@element-plus/icons-vue";
import { debounce } from 'lodash-es';
const props = defineProps({
columns: { type: Array, required: true },
data: { type: Array, required: true },
@@ -144,8 +141,15 @@ const props = defineProps({
immediate: { type: Boolean, default: true },
// 是否在激活时刷新数据
refreshOnActivated: { type: Boolean, default: true },
// 是否展示下拉菜单 (如果按钮是鼠标移入移出 不建议开启下拉菜单 会导致弹层的问题)
showDropdown: { type: Boolean, default: false },
});
const DEFAULT_PAGINATION = {
layout: "prev, pager, next",
background: false,
}
const emit = defineEmits([
"current-change",
"size-change",
@@ -159,6 +163,39 @@ const innerLoading = ref(false);
// 默认按钮长度
const MAX_BUTTON_LENGTH = 3;
// 高度模块的计算
const tableContainerRef = ref(null);
const containerHeight = ref(0);
const minHeight = 400; // 最小高度值
// 监听窗口大小变化
const handleResize = debounce(() => {
updateContainerHeight();
},100);
// 动态计算表格容器高度
const updateContainerHeight = async () => {
await nextTick();
if (tableContainerRef.value) {
// 获取容器相对于视口的位置
const element = tableContainerRef.value;
const rect = element.getBoundingClientRect();
const containerTop = rect.top;
// 计算从容器位置到浏览器底部的可用高度
const availableHeight = window.innerHeight - containerTop -20;
containerHeight.value = Math.max(availableHeight, minHeight);
} else {
// 如果元素尚未渲染,使用默认偏移值
containerHeight.value = Math.max(window.innerHeight - 100, minHeight);
}
};
const tableHeight = computed(() => {
// 为页码预留空间
const paginationHeight = props.pagination ? 38 : 0;
return (containerHeight.value - paginationHeight) + 'px';
});
// 标记是否是首次挂载
let isFirstMount = true;
@@ -183,11 +220,12 @@ const params = computed(() => {
});
// 请求方法
const refresh = async () => {
const refresh = async (isReset:boolean=false) => {
if (!props.requestApi) return;
innerLoading.value = true;
try {
const res = await props.requestApi(params.value);
const requestParams = isReset ? {...params.value,pageNo:1} : params.value;
const res = await props.requestApi(requestParams);
emit("update:data", res?.records || []);
emit("update:total", res?.total || 0);
} catch (error) {
@@ -208,7 +246,17 @@ const handleSizeChange = (val) => {
if (props.requestApi) refresh();
};
onMounted(() => {
// 按钮支持 disabledFn函数
const getButtonDisabled = (button, row) => {
if (button.disabledFn && typeof button.disabledFn === 'function') {
return button.disabledFn(row);
}
return button.disabled || false;
};
onMounted(async () => {
await updateContainerHeight();
window.addEventListener('resize', handleResize);
if (props.immediate) {
refresh();
}
@@ -218,21 +266,36 @@ onMounted(() => {
});
// 兼容设置了keep-alive
onActivated(() => {
onActivated(async () => {
if (!isFirstMount && props.refreshOnActivated) {
refresh();
}
await updateContainerHeight();
});
// 组件销毁的生命周期
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
// 暴露 refresh 方法给外部使用
defineExpose({ refresh });
defineExpose({ refresh,reset:()=>refresh(true) });
// 分页配置
const paginationConfig = computed(() => ({
layout: "prev, pager, next",
background: false,
...props.pagination,
}));
const paginationConfig = computed(() =>{
if (typeof props.pagination === 'boolean') {
return DEFAULT_PAGINATION;
}
if (typeof props.pagination === 'object' && props.pagination !== null) {
return {
...DEFAULT_PAGINATION,
...props.pagination
};
}
return DEFAULT_PAGINATION;
});
// action按钮组的数据
const handleButtonClick = (button, row) => {
@@ -250,6 +313,9 @@ const shouldHideButton = (button, row) => {
const getVisibleButtonCount = (col) => {
const { actions, maxButtons } = col;
const totalButtons = maxButtons || MAX_BUTTON_LENGTH;
if(!props.showDropdown){
return actions.length;
}
return totalButtons === actions.length ? totalButtons : Math.min(totalButtons, actions.length)-1;
};
@@ -259,7 +325,7 @@ const hasDropdownPermission = (dropdownActions) => {
);
};
const getButtonProps = (button) => {
const { label, onClick, show, permission, ...buttonProps } = button;
const { label, onClick, show, permission,disabledFn, ...buttonProps } = button;
return buttonProps;
};
</script>
@@ -267,7 +333,7 @@ const getButtonProps = (button) => {
<style scoped lang="scss">
.pro-table-container {
background: #fff;
border-radius: 12px;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
border: 1px solid #f0f0f0;
@@ -277,11 +343,15 @@ const getButtonProps = (button) => {
background-color: #fbfcfd;
}
}
:deep(.el-table__inner-wrapper:before){
--el-table-border-color:transparent;
}
/* 底部容器样式对应图片中的布局 */
.pro-table-footer {
padding: 3px 24px;
background-color: #fcfdfe;
border-top: 1px solid #E2E8F0;
}
.mj-footer-content {
@@ -302,7 +372,7 @@ const getButtonProps = (button) => {
pointer-events: none;
transition: opacity 0.25s ease-in, transform 0.25s ease-in;
}
:deep(.el-table__row:hover) .action-group {
:deep(.el-table__row:hover) .action-group,:deep(.row-dropdown-active) .action-group{
opacity: 1;
transform: translateX(0);
pointer-events: auto;

View File

@@ -0,0 +1,535 @@
<template>
<div ref="tableContainerRef" class="pro-table-v2-container">
<el-table-v2
ref="tableRef"
v-bind="$attrs"
:columns="adaptedColumns"
:data="innerData"
:width="tableSize.width"
:height="tableSize.height"
:fixed="true"
@rows-rendered="handleRowsRendered"
>
<template #overlay v-if="loading && innerData.length === 0">
<div class="v2-initial-loading">
<div class="loading-content">
<el-icon class="is-loading" :size="30"><Loading /></el-icon>
</div>
</div>
</template>
<template #footer>
<div class="v2-infinite-footer">
<div v-if="loading && innerData.length > 0" class="loading-more">
<el-icon class="is-loading"><Loading /></el-icon>
</div>
<div v-else-if="noMore" class="no-more">
<span class="line"></span>
<span class="text">已经到底了~</span>
<span class="line"></span>
</div>
</div>
</template>
<!-- 空数据 -->
<template #empty>
<el-empty description="暂无数据" :image-size="100" />
</template>
</el-table-v2>
</div>
</template>
<script setup lang="tsx">
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue";
import {
ElTag,
ElButton,
ElText,
ElTooltip,
ElCheckbox,
useLocale,
} from "element-plus";
import { debounce } from "lodash-es";
import dayjs from "dayjs";
import { DictManage } from "@/dict";
import { usePermission } from "@/utils/permission";
const { checkPermission } = usePermission();
// 组件属性
const props = defineProps({
columns: { type: Array, required: true },
requestApi: { type: Function, required: true },
initParam: { type: Object, default: () => ({}) },
pageSize: { type: Number, default: 20 },
selection: { type: Boolean, default: false }, // 是否开启多选
selectionConfig:{
type: Object,
default:()=>({
width: 50,
fixed: false,
align: "center",
key: "selection"
})
}
});
// 内部数据状态
const tableRef = ref(null);
const tableContainerRef = ref(null);
const loading = ref(false);
const innerData = ref<any[]>([]);
const total = ref(0);
const pageNo = ref(1);
const tableSize = ref({ width: 0, height: 400 });
const selectedKeys = ref<Set<string | number>>(new Set());
const isAllSelectedMode = ref(false); //全选模式
const noMore = computed(() => {
if (total.value <= 0) return false;
const isReachTotal = innerData.value.length >= total.value;
return isReachTotal;
});
const SelectionCell = (props: {
value: boolean;
intermediate?: boolean;
onChange: (val: any) => void;
}) => (
<ElCheckbox
modelValue={props.value}
indeterminate={props.intermediate}
onChange={props.onChange}
/>
);
// --- 1. 核心渲染工厂 (内置常用业务组件) ---
// 定义一个内部小组件处理溢出逻辑
const OverflowTooltip = defineComponent({
props: {
val: { type: String, default: "" },
},
setup(props) {
const isOverflow = ref(false);
const textRef = ref<HTMLElement | null>(null);
const checkOverflow = () => {
if (textRef.value) {
// 计算溢出
isOverflow.value =
textRef.value.scrollWidth > textRef.value.clientWidth;
}
};
return () => (
<ElTooltip
content={props.val}
placement="top"
effect="dark"
disabled={!isOverflow.value}
>
{{
default: () => (
<div
ref={textRef}
class="mj-ellipsis-cell"
onMouseenter={checkOverflow}
>
{props.val}
</div>
),
}}
</ElTooltip>
);
},
});
const RenderFactory = {
// 状态点/标签
status: (scope: any, col: any) => {
const val = scope.rowData[col.prop];
let rawOptions = col.options;
// 如果是 ref 或 computed先取 .value
if (rawOptions && typeof rawOptions === "object" && "value" in rawOptions) {
rawOptions = rawOptions.value;
}
// 如果是函数则执行
if (typeof rawOptions === "function") {
rawOptions = rawOptions();
}
// 强制转为数组,防止 find 报错
const currentOptions = Array.isArray(rawOptions) ? rawOptions : [];
const target = currentOptions.find((opt: any) => opt.value == val);
return (
<div
class="mj-status-dot"
style={{
"--data-status-color": DictManage.statusDictColor[target?.value],
cursor: "pointer",
}}
onClick={() => {
if (col.onClick) {
col.onClick({ cellValue: val, rowData: scope.rowData });
}
}}
>
{target?.label || "-"}
</div>
);
},
// 日期格式化
date: (scope: any, col: any) => {
const val = scope.rowData[col.prop];
return (
<span>
{val ? dayjs(val).format(col.format || "YYYY-MM-DD HH:mm") : "-"}
</span>
);
},
// 文本自动省略
ellipsis: (scope: any, col: any) => {
const val = scope.rowData[col.prop] ?? "-";
return <OverflowTooltip val={val} />;
},
};
/**
* 局部更新某一行数据
* @param id 唯一标识
* @param rowData 新的数据对象(可以是部分属性)
*/
const updateRow = (id: string | number, rowData: object) => {
const index = innerData.value.findIndex((item) => item.id === id);
if (index !== -1) {
// 使用合并方式,保留原有的其他字段
innerData.value[index] = { ...innerData.value[index], ...rowData };
}
};
// 获取当前已加载的数据
const getSelectedRows = () => {
return innerData.value.filter(row => selectedKeys.value.has(row.id));
};
/**
* 删除某一行数据
* @param id 唯一标识
*/
const removeRow = (id: string | number) => {
const index = innerData.value.findIndex((item) => item.id == id);
if (index !== -1) {
innerData.value.splice(index, 1);
total.value = Math.max(0, total.value - 1);
// 💡 关键:删除后如果当前显示的数据太少(比如不足一屏),自动去接下一页
if (innerData.value.length < 15 && !noMore.value) {
fetchTableData(false); // 这里的 false 表示“追加”数据来补位
}
// 如果删除后这一页空了,且还有数据,也去拉取
if (innerData.value.length === 0 && total.value > 0) {
fetchTableData(true);
}
}
};
/**
* 插入一条新数据(通常用于新增成功后插到最前面)
* @param rowData 完整的数据对象
*/
const addRow = (rowData: object) => {
innerData.value.unshift(rowData);
total.value++;
};
// --- 适配 Columns 配置 ---
const adaptedColumns = computed(() => {
const containerWidth = tableSize.value.width;
if (containerWidth <= 0) return [];
// 从配置中提取宽度,如果没有传入则默认为 50
const hasSelection = props.selection;
const selectionWidth = hasSelection ? (props.selectionConfig?.width || 50) : 0;
// 1. 找出用户配置中没有设置宽度的列
const autoColumns = props.columns.filter((col: any) => !col.width);
// 2. 计算所有显式设置了宽度的用户列总和
const userFixedWidthTotal = props.columns
.filter((col: any) => col.width)
.reduce((prev, curr: any) => prev + Number(curr.width), 0);
// 3. 计算剩余可用宽度 (如果开启了 selection需要额外扣除 selection 的宽度)
const occupiedWidth = userFixedWidthTotal + selectionWidth;
const remainingWidth = Math.max(containerWidth - occupiedWidth, 0);
// 4. 计算自适应列宽度
const perAutoWidth = autoColumns.length > 0
? Math.floor(remainingWidth / autoColumns.length)
: 0;
// 5. 映射用户列
const userColumns = props.columns.map((col: any) => {
const isAuto = !col.width;
const finalWidth = isAuto ? Math.max(perAutoWidth, 100) : Number(col.width);
return {
key: col.prop,
dataKey: col.prop,
title: col.label,
width: finalWidth,
flexGrow: isAuto ? 1 : 0,
flexShrink: 1,
fixed: col.fixed,
align: col.align || "left",
cellRenderer: (scope: any) => {
// ... 此处保留你原有的 cellRenderer 逻辑
if (typeof col.render === "function") return col.render(scope);
if (col.valueType && RenderFactory[col.valueType]) {
return RenderFactory[col.valueType](scope, col);
}
return <span class="v2-cell-text">{scope.rowData[col.prop] ?? "-"}</span>;
},
};
});
// 6. 如果开启了多选,在数组头部注入多选列
if (hasSelection) {
const selectionColumn = {
key: props.selectionConfig?.key || "selection",
width: selectionWidth,
fixed: props.selectionConfig?.fixed ?? false,
align: props.selectionConfig?.align || "center",
// 表头:全选
headerCellRenderer: () => {
const isAllSelected = innerData.value.length > 0 && selectedKeys.value.size >= innerData.value.length;
const isIndeterminate = selectedKeys.value.size > 0 && !isAllSelected;
const onAllChange = (val: any) => {
isAllSelectedMode.value = val; // 记录全选模式
if (val) {
innerData.value.forEach(row => selectedKeys.value.add(row.id));
} else {
selectedKeys.value.clear();
}
};
return <SelectionCell value={isAllSelected} intermediate={isIndeterminate} onChange={onAllChange} />;
},
// 单元格:单选
cellRenderer: (scope: any) => {
const rowId = scope.rowData.id;
const checked = selectedKeys.value.has(rowId);
const onRowChange = (val: any) => {
if (val){
selectedKeys.value.add(rowId);
if (selectedKeys.value.size === innerData.value.length) isAllSelectedMode.value = true;
}else{
selectedKeys.value.delete(rowId);
isAllSelectedMode.value = false;
}
};
return <SelectionCell value={checked} onChange={onRowChange} />;
},
};
return [selectionColumn, ...userColumns];
}
return userColumns;
});
// --- 请求逻辑 ---
const fetchTableData = async (isReset = false) => {
if (loading.value) return;
if (!isReset && noMore.value) return;
if (isReset) {
pageNo.value = 1;
innerData.value = [];
}
loading.value = true;
try {
const params = {
pageNo: pageNo.value,
pageSize: props.pageSize,
...props.initParam,
};
const res = await props.requestApi(params);
const records = res?.records || [];
// 联动模式 全选滚动加载数据默认添加到Set中
if (isAllSelectedMode.value) {
records.forEach((row: any) => {
if (row.id !== undefined) selectedKeys.value.add(row.id);
});
}
innerData.value = isReset ? records : [...innerData.value, ...records];
total.value = res?.total || 0;
pageNo.value++;
} finally {
loading.value = false;
}
};
// --- 交互逻辑 ---
const updateSize = () => {
if (tableContainerRef.value) {
// 1. offsetWidth 包含 border 和 padding是最准确的外盒宽度
const containerWidth = tableContainerRef.value.offsetWidth;
// 不要减 2让表格填满
tableSize.value.width = containerWidth;
const rect = tableContainerRef.value.getBoundingClientRect();
const remainingHeight = window.innerHeight - rect.top - 24;
tableSize.value.height = Math.max(remainingHeight, 200);
}
};
const handleRowsRendered = ({ endRowIndex }: any) => {
// 阈值调大一点10保证滚动流畅用户无感知加载
if (endRowIndex >= innerData.value.length - 10) {
fetchTableData();
}
};
const handleResize = debounce(updateSize, 200);
onMounted(() => {
updateSize();
window.addEventListener("resize", handleResize);
fetchTableData(true);
});
onUnmounted(() => window.removeEventListener("resize", handleResize));
// 暴露 API
defineExpose({
refresh: () => fetchTableData(true),
updateRow,
removeRow,
addRow,
updateSize,
getSelection: () => getSelectedRows(),
clearSelection: () => {
isAllSelectedMode.value = false;
selectedKeys.value.clear();
},
getCurrentParams: () => ({
pageNo: pageNo.value - 1, // 因为 fetch 完后 pageNo 会自增,所以要减 1 才是当前页
pageSize: props.pageSize,
...props.initParam,
}),
innerData,
});
</script>
<style scoped lang="scss">
.pro-table-v2-container {
width: 100%;
height: 100%;
background-color: #fff;
border-radius: 4px;
/* 移除外边框,让样式更融入页面 */
.el-table-v2 {
box-shadow: 0 0 6px #eef1f6;
}
// 1. 初始全屏 Loading
.v2-initial-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(4px);
z-index: 100;
.loading-content {
text-align: center;
color: var(--el-color-primary);
p {
margin-top: 10px;
font-size: 14px;
}
}
}
// 2. 底部流式加载样式
.v2-infinite-footer {
padding: 20px 0;
color: #909399;
font-size: 13px;
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.is-loading {
animation: rotating 2s linear infinite;
}
}
.no-more {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
opacity: 0.6;
.line {
width: 30px;
height: 1px;
background-color: #dcdfe6;
}
.text {
font-style: italic;
}
}
}
}
// 适配 el-table-v2 核心样式
:deep(.el-table-v2__main) {
background: #fff;
box-sizing: border-box;
}
:deep(.el-table-v2__header-wrapper) {
border-bottom: 1px solid #f2f3f5;
.el-table-v2__header-cell {
background-color: #f8fafc;
color: #303133;
font-weight: 600;
}
}
:deep(.el-table-v2__row) {
border-bottom: 1px solid #f9fafb;
&:hover {
background-color: #f5f7fa;
}
}
:deep(.operation-column-cell) {
// 默认隐藏内容,但保留位置
.v2-operation-btns {
visibility: hidden;
opacity: 0;
transition: opacity 0.2s;
display: flex;
justify-content: center;
.el-button + .el-button {
margin-left: 8px;
}
}
}
// 2. 当鼠标移入行时,显示该行内的操作按钮
:deep(.el-table-v2__row:hover) {
background-color: #f5f7fa; // 顺便加个行高亮
.operation-column-cell .v2-operation-btns {
visibility: visible;
opacity: 1;
}
}
</style>

View File

@@ -1,6 +1,8 @@
<template>
<div class="stage-breadcrumbs">
<div class="mj-panel-title">{{ title }}</div>
<div class="stage-breadcrumbs" :class="styleClass">
<slot name="title">
<div class="mj-panel-title">{{ title }}</div>
</slot>
<div class="stage-breadcrumbs-content">
<slot name="content"></slot>
</div>
@@ -12,13 +14,14 @@
<script setup lang="ts">
defineOptions({ name: "StageBreadcrumbs" });
const { title } = defineProps<{
title: string;
const { title,styleClass,showTitle=true } = defineProps<{
title?: string;
styleClass?: string;
}>();
</script>
<style lang="scss" scoped>
.stage-breadcrumbs{
padding: $mj-padding-standard 0;
padding: 0 0 $mj-padding-standard 0;
align-items: center;
background-color: transparent;
border-bottom: 1px solid #E2E8F099;
@@ -48,4 +51,8 @@ const { title } = defineProps<{
}
.stage-breadcrumbs-list{
padding: 0;
}
</style>

View File

@@ -1,9 +1,8 @@
<template>
<div class="mj-standard-menu">
<el-menu
:default-active="activeIndex"
:active-text-color="mode === 'horizontal' ? '#409EFF' : undefined"
class="mj-menu"
:default-active="activeIndex"
:class="['mj-menu',menuClass]"
:mode="mode"
:collapse="isCollapse"
@select="handleMenuSelect"
@@ -20,7 +19,7 @@
<template #title>{{ row.meta.title }}</template>
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="item.path">
<el-menu-item v-else :index="getFirstChildPath(item)" :disabled="item.meta.disabled">
<el-icon v-if="item.meta?.icon"><component :is="item.meta.icon" /></el-icon>
<template #title>{{ item.meta.title }}</template>
</el-menu-item>
@@ -29,15 +28,15 @@
</div>
</template>
<script setup lang="ts">
import { Location } from '@element-plus/icons-vue'
defineOptions({ name: "standardMenu" })
const route = useRoute();
const {mode="vertical",menuList,isCollapse,activeMenu} = defineProps<{
const {mode="vertical",menuList,isCollapse,activeMenu,menuClass} = defineProps<{
mode?: 'vertical' | 'horizontal'
menuList: any[]
isCollapse?:boolean
activeMenu?: string
menuClass?: string
}>()
const emit = defineEmits<{
@@ -69,6 +68,18 @@ const resolvePath = (parentPath: string, childPath: string) => {
// 4. 返回拼接后的路径
return `${parent}/${child}`;
};
/**
* 获取菜单项的跳转路径
* 如果是顶级菜单且有子项,点击应跳转到第一个子项
*/
const getFirstChildPath = (item: any) => {
// 如果是顶级菜单且有子菜单
if (item.children && item.children.length > 0) {
return resolvePath(item.path, item.children[0].path);
}
// 如果没有子菜单,直接返回 item.path但要确保它是以 / 开头
return item.path.startsWith('/') ? item.path : `/${item.path}`;
};
// 处理菜单选中事件
const handleMenuSelect = (index: string) => {
@@ -101,7 +112,7 @@ const handleMenuSelect = (index: string) => {
display: inline-block;
white-space: nowrap;
width: auto;
max-width: 1000px; // 设置一个足够大的值
max-width: 1000px;
overflow: hidden;
transition: max-width 0.25s, opacity 0.25s, width 0.25s;
opacity: 1;
@@ -139,5 +150,43 @@ const handleMenuSelect = (index: string) => {
flex-shrink: 0;
transition: none;
}
// 菜单样式类
.mj-top_menu{
--el-menu-hover-bg-color:transparent;
--el-menu-active-color:var(--el-color-primary);
--el-menu-text-color:#62748E;
}
.mj-aside_menu{
--el-menu-hover-bg-color:#F8FAFC;
--el-menu-item-height:46px;
--el-menu-item-padding:20px;
--el-menu-item-border-color:#DBEAFE;
--el-menu-item-active-color:#EFF6FF;
padding: 0 12px;
.el-menu-item{
margin-bottom: 3px;
}
.el-menu-item.is-active{
background-color: var(--el-menu-item-active-color);
border: 1px solid var(--el-menu-item-border-color);
border-radius: var(--el-menu-item-padding);
}
.el-menu-item:hover{
border-radius: var(--el-menu-item-padding);
transition: transform .12s ease;
.el-icon{
transform: scale(1.08);
}
}
}
// 收缩菜单样式
.el-menu--collapse{
--el-menu-hover-bg-color:transparent;
--el-menu-item-border-color:transparent;
--el-menu-item-active-color:transparent;
}
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<div class="user-selector-modules">
<name-avatar :name="name" :src="avatar" :size="size" style="margin-right: 12px" />
<span>{{ userAvatarName }}</span>
</div>
</template>
<script setup lang="ts">
import nameAvatar from "../nameAvatar/index.vue";
defineOptions({ name: "UserSelector" });
const props = defineProps({
userAvatarName: { type: String, default: "" },
avatar: { type: String, default: "" },
size: { type: Number, default: 24 },
name: { type: String, default: "" },
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="view-switcher">
<div class="active-indicator" :style="indicatorStyle"></div>
<div
class="switcher-item"
:class="{ active: modelValue === 'grid' }"
@click="toggleView('grid')"
>
<el-icon><Grid /></el-icon>
</div>
<div
class="switcher-item"
:class="{ active: modelValue === 'list' }"
@click="toggleView('list')"
>
<el-icon><List /></el-icon>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { Grid, List } from "@element-plus/icons-vue";
const props = defineProps<{
modelValue: "grid" | "list";
}>();
const emit = defineEmits(["update:modelValue"]);
// 统一样式变量
const PADDING = 3; // 容器内边距
const ITEM_WIDTH = 32; // 每个图标按钮的宽度
const indicatorStyle = computed(() => {
const offset = props.modelValue === "grid" ? 0 : ITEM_WIDTH;
return {
width: `${ITEM_WIDTH}px`,
transform: `translateX(${offset}px)`,
};
});
const toggleView = (view: "grid" | "list") => {
emit("update:modelValue", view);
};
</script>
<style lang="scss" scoped>
.view-switcher {
/* 核心高度控制 */
$total-height: 38px;
$padding: 3px;
$inner-height: $total-height - ($padding * 2); // 32px
display: inline-flex;
position: relative;
height: $total-height;
padding: $padding;
background-color: #f0f2f5;
border-radius: 6px;
box-sizing: border-box;
user-select: none;
cursor: pointer;
.switcher-item {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 32px; // 保持正方形
height: $inner-height;
color: #606266;
transition: color 0.12s ease;
.el-icon {
font-size: 16px;
/* 核心:设置 SVG 的过渡效果 */
svg {
transition: stroke-width 0.12s ease, transform 0.12s ease;
stroke: currentColor;
stroke-width: 0; // 默认不加粗
}
}
&.active {
color: var(--el-color-primary);
}
&:hover {
color: var(--el-color-primary);
.el-icon svg {
stroke-width: 1.5;
transform: scale(1.1);
}
}
}
.active-indicator {
position: absolute;
z-index: 1;
top: $padding;
left: $padding;
height: $inner-height;
background-color: #fff;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
}
</style>

View File

@@ -1,29 +1,13 @@
// 后台 - 字典管理模块-字典内容信息
// 字典状态映射
const statusDict = {
1: '正常',
0: '禁用'
}
// 字典状态颜色
const statusDictColor = {
1:'#66E5BE',
0:'#90A1B9'
}
// 设置字典转换为目标格式
const statusOptions = Object.keys(statusDict).map((key) => {
return {
label: statusDict[key],
value: Number(key)
}
})
export default {
statusDict,
statusDictColor,
statusOptions
}

View File

@@ -1,5 +1,13 @@
import DictManage from './dictManage';
const Dict = { DictManage }
export { DictManage }
export default Dict;
const modules = import.meta.glob('./*.{vue,ts,js}', { eager: true });
const components: Record<string, any> = {};
Object.entries(modules).forEach(([path, module]: [string, any]) => {
// 过滤掉 index 文件本身
if (path.includes('index')) return;
const name = path.replace(/^\.\/(.*)\.\w+$/, '$1');
components[name] = module.default || module;
});
// 2. 统一导出
export const {
DictManage,
} = components;
export default components;

View File

@@ -0,0 +1,97 @@
import { ref, reactive, nextTick } from 'vue';
export type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
export interface PopoverOptions {
placement?: Placement;
offsetX?: number;
offsetY?: number;
// 这里的 width 变成“最小预估宽度”,防止首次渲染跳动
minWidth?: number;
}
export interface MenuPosition {
top: string;
left: string;
position: 'fixed';
}
export function useUniversalPopover<T = any>(options: PopoverOptions = {}) {
const {
placement = 'bottom-end',
offsetX = 0,
offsetY = 8,
minWidth = 120
} = options;
const visible = ref(false);
const activeData = ref<T | null>(null);
const menuStyle = reactive<MenuPosition>({
top: '0px',
left: '0px',
position: 'fixed'
});
const openMenu = (e: MouseEvent, data: T) => {
// 切换逻辑:点击同一个则关闭
if (visible.value && activeData.value === data) {
closeMenu();
return;
}
activeData.value = data;
visible.value = true;
// 获取触发源位置
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
nextTick(() => {
// 1. 获取刚刚渲染出来的弹窗 DOM 实例
// 注意:这里需要确保你的 ActionMenu 组件根类名一致
const menuEl = document.querySelector('.mj-universal-menu') as HTMLElement;
if (!menuEl) return;
const realWidth = menuEl.offsetWidth || minWidth;
const realHeight = menuEl.offsetHeight || 0;
const screenWidth = window.innerWidth;
let top = 0;
let left = 0;
// 2. 根据方位计算坐标
if (placement.includes('bottom')) {
top = rect.bottom + offsetY;
} else if (placement.includes('top')) {
top = rect.top - realHeight - offsetY;
}
if (placement.includes('end')) {
// 右对齐:触发源右侧 - 弹窗宽度
left = rect.right - realWidth + offsetX;
} else {
// 左对齐
left = rect.left + offsetX;
}
// 3. 边界检测:防止超出屏幕
// 左边界
if (left < 10) left = 10;
// 右边界
if (left + realWidth > screenWidth - 10) {
left = screenWidth - realWidth - 10;
}
menuStyle.top = `${top}px`;
menuStyle.left = `${left}px`;
});
};
const closeMenu = () => {
visible.value = false;
activeData.value = null;
};
return { visible, activeData, menuStyle, openMenu, closeMenu };
}

79
src/hooks/useDictData.ts Normal file
View File

@@ -0,0 +1,79 @@
import { ref, reactive, onMounted } from 'vue';
import { getDictMap,getDictMapLevel } from '@/api';
// 全局静态缓存,跨组件共享
const dictCache = reactive({});
const pendingPromises = new Map();
export function useDict(codes) {
const dicts = ref({});
const loading = ref(false);
const fetchDicts = async () => {
if (!codes) return;
const codeArray = codes.split(',').map(s => s.trim());
const result = {};
const needFetch = [];
// 1. 区分哪些在缓存,哪些需要查
codeArray.forEach(code => {
if (dictCache[code]) {
result[code] = dictCache[code];
} else {
needFetch.push(code);
}
});
if (needFetch.length === 0) {
dicts.value = result;
return;
}
// 2. 处理并发合并
const fetchKey = needFetch.sort().join(',');
if (!pendingPromises.has(fetchKey)) {
const p = getDictMap(codes).then(res => {
const data = res || [];
Object.assign(dictCache, data);
pendingPromises.delete(fetchKey);
return data;
});
pendingPromises.set(fetchKey, p);
}
loading.value = true;
try {
const remoteData = await pendingPromises.get(fetchKey);
dicts.value = { ...result, ...remoteData };
} finally {
loading.value = false;
}
};
const fetchLevel = async (code, parentId) => {
if (!code || parentId === undefined) return [];
// 构造层级缓存 Key例如 "GENDER_1"
const cacheKey = `${code}_${parentId}`;
if (dictCache[cacheKey]) {
return dictCache[cacheKey];
}
loading.value = true;
try {
// 假设 getDictMapLevel 接受 code 和 parentId
const res = await getDictMapLevel(code, parentId);
const data = res || [];
// 写入全局缓存
dictCache[cacheKey] = data;
return data;
} finally {
loading.value = false;
}
};
onMounted(fetchDicts);
return { dicts, loading,refresh: fetchDicts,fetchLevel };
}

View File

@@ -0,0 +1,45 @@
import { ref, shallowRef, computed, watch } from 'vue';
export function useLocalManager<T>(initialDataGetter: () => T[], pageSize = 20) {
// 1. 全量数据池
const fullData = shallowRef<T[]>([]);
// 2. 内部展示条数
const displayCount = ref(pageSize);
// 3. 监听初始数据的变化(处理后端异步返回)
watch(initialDataGetter, (newVal) => {
if (Array.isArray(newVal)) {
// 全量去重,确保内部池子纯净
const map = new Map();
newVal.forEach(item => { if((item as any).id) map.set((item as any).id, item) });
const uniqueData = Array.from(map.values()) as T[];
fullData.value = uniqueData;
// 如果数据被删减到小于当前水位,重置水位
if (uniqueData.length < displayCount.value) {
displayCount.value = Math.max(pageSize, uniqueData.length);
}
}
}, { immediate: true,deep:true });
// 4. 切片数据UI 渲染对象)
const displayData = computed(() => {
return fullData.value.slice(0, displayCount.value);
});
const noMore = computed(() => displayCount.value >= fullData.value.length);
// 加载更多
const loadMore = () => {
if (noMore.value) return;
displayCount.value += pageSize;
};
return {
displayData,
displayCount,
loadMore,
noMore,
};
}

View File

@@ -0,0 +1,52 @@
// useRelativeTime.ts
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn' // 引入中文语言包
// 初始化插件和语言
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
export function useRelativeTime() {
/**
* 核心转换函数
* @param value 时间戳、Date对象或ISO字符串
*/
const formatTime = (value: string | number | Date): string => {
if (!value) return ''
const target = dayjs(value)
const now = dayjs()
const diffSeconds = now.diff(target, 'second')
const diffDays = now.diff(target, 'day')
// 1. 极短时间内60秒内显示“刚刚”
if (diffSeconds < 60) {
return '刚刚'
}
// 2. 一天以内(显示 10分钟前, 1小时前等
if (diffDays < 1) {
return target.fromNow()
}
// 3. 超过一天但在一年以内(显示 10-27
if (diffDays < 365) {
return target.format('MM-DD HH:mm')
}
// 4. 超过一年(显示 2023-10-27
return target.format('YYYY-MM-DD')
}
// 如果你需要判断“是否超过可删除时间”比如5分钟内可删除
const canAction = (value: string | number | Date, limitMinutes: number = 5) => {
return dayjs().diff(dayjs(value), 'minute') < limitMinutes
}
return {
dayjs, // 同时也暴露原生的 dayjs 方便外部灵活使用
formatTime,
canAction
}
}

View File

@@ -0,0 +1,77 @@
import { reactive, toRefs } from "vue";
import { debounce } from "lodash-es";
export function useSelectLoadMore(config: any) {
const { fetchApi, pageSize = 10, delay = 300 } = config;
const state = reactive({
options: [] as any[],
remoteLoading: false,
loadMore: false,
noMore: false,
pageNo: 1,
query: "",
});
const executeFetch = async (isRefresh: boolean) => {
// 拦截逻辑
if (!isRefresh && (state.loadMore || state.noMore)) return;
if (isRefresh) {
state.pageNo = 1;
state.noMore = false;
state.remoteLoading = true;
} else {
state.loadMore = true;
}
try {
const res = await fetchApi({
pageNo: state.pageNo,
pageSize: pageSize,
keyword: state.query,
});
const newList = res || [];
if (isRefresh) {
state.options = newList;
} else {
// 滚动加载:追加数据,不置空,不触发 select 的 loading
state.options.push(...newList);
}
// 探测 noMore
if (newList.length < pageSize) {
state.noMore = true;
} else {
state.pageNo++;
}
} finally {
// 请求结束,关闭所有状态
state.remoteLoading = false;
state.loadMore = false;
}
};
const remoteMethod = debounce(async (query: string) => {
state.query = query;
await executeFetch(true);
}, delay);
const handleLoadMore = async () => {
await executeFetch(false);
};
const onVisibleChange = async (visible: boolean) => {
if (visible && state.options.length === 0) {
await executeFetch(true);
}
};
return {
...toRefs(state),
remoteMethod,
handleLoadMore,
onVisibleChange,
};
}

View File

@@ -0,0 +1,68 @@
import { ElMessage } from "element-plus";
/**
* @param tableRef ProTableV2 的组件实例引用
*/
interface ActionOptions {
showMsg?: boolean; // 是否显示操作成功提示
}
export const useTableAction = (tableRef: any) => {
/**
* 执行操作并静默更新单行数据
* @param apiPromise 调用的操作接口(如状态切换、编辑提交)
* @param id 当前行 ID
* @param fetchDataApi 可选:列表请求函数,若传入则在成功后自动拉取最新行数据
*/
const handleAction = async (
api: Promise<any> | (() => Promise<any>),
id?: string | number,
fetchDataApi?: Function,
options = { showMsg: true }
) => {
try {
const res = typeof api === 'function' ? await api() : await api;
if (options.showMsg) ElMessage.success("操作成功");
// 局部更新逻辑... (同上)
if (id && fetchDataApi) {
const currentParams = tableRef.value?.getCurrentParams();
const listRes = await fetchDataApi(currentParams);
const records = listRes?.records || listRes?.data?.records || [];
const latestRowData = records.find((item: any) => item.id == id);
if (latestRowData) tableRef.value.updateRow(id, latestRowData);
}
return res;
} catch (error) {
console.error(error);
throw error; // 抛出错误让外部 catch
}
};
/**
* 封装删除逻辑
* @param id 行 ID
* @param deleteApi 删除接口函数
*/
const handleDelete = async (
deleteApi: (id: any) => Promise<any>,
...args: any[]
) => {
try {
const res = await deleteApi(...args);
const id = args[0];
if(id){
tableRef.value?.removeRow(id);
}
return res;
} catch (error) {
// 捕获取消行为或接口错误
throw error;
}
};
return {
handleAction,
handleDelete,
};
};

View File

@@ -1,90 +0,0 @@
// useTokenRefresh.ts
import { ref } from "vue";
import axios from "axios";
interface TokenData {
accessToken: string; // token内容
refreshToken: string; // 刷新的token内容
expiresAt?: number; // 过期时间戳
}
export default function useTokenRefresh(baseUrl: string) {
let refreshPromise: Promise<TokenData> | null = null;
let lastRefreshError: any = null;
// 当前Token数据使用localStorage持久化 可根据实际情况改成localStorage存储
const tokenData = ref<TokenData>({
accessToken: localStorage.getItem("accessToken") || "",
refreshToken: localStorage.getItem("refreshToken") || "",
expiresAt: Number(localStorage.getItem("expiresAt")) || 0,
});
// 核心刷新方法
const refreshToken = async (): Promise<TokenData> => {
if (refreshPromise) return refreshPromise;
// 如果上次刷新失败,短时间内不重复刷新,直接抛错
if (lastRefreshError) {
return Promise.reject(lastRefreshError);
}
const plainClient = axios.create({
baseURL: baseUrl,
timeout: 5 * 1000,
headers: {
"Content-Type": "application/json;charset=utf-8",
},
});
refreshPromise = (async () => {
try {
const refreshToken = getRefreshToken();
if (!refreshToken) throw new Error("refreshToken is null");
const res = await plainClient.post("/auth/refresh", {
refreshToken,
});
const newTokenData: TokenData = {
accessToken: res.data.accessToken,
refreshToken: res.data.refreshToken,
expiresAt: Date.now() + res.data.expiresIn * 1000,
};
setTokens(newTokenData);
lastRefreshError = null; // 成功清除错误
return newTokenData;
} catch (error) {
// 刷新失败处理
lastRefreshError = error;
clearTokens();
throw error;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
};
// 保存token
const setTokens = (data: TokenData) => {
tokenData.value = data;
localStorage.setItem("accessToken", data.accessToken);
localStorage.setItem("refreshToken", data.refreshToken);
localStorage.setItem("expiresAt", data.expiresAt.toString());
};
// 清理Token
const clearTokens = () => {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
localStorage.removeItem("expiresAt");
tokenData.value = { accessToken: "", refreshToken: "", expiresAt: 0 };
};
// 获取当前Token
const getAccessToken = () => tokenData.value.accessToken;
// 获取refreshToken
const getRefreshToken = () => tokenData.value.refreshToken;
return {
refreshToken,
getRefreshToken,
setTokens,
getAccessToken,
clearTokens,
};
}

View File

@@ -8,16 +8,13 @@ import zhCn from "element-plus/es/locale/lang/zh-cn";
import en from "element-plus/es/locale/lang/en";
import Directives from '@/utils/directives';
import '@/styles/common.scss';
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
// 全局导入element ui样式类
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/notification/style/css'
import 'element-plus/es/components/message-box/style/css'
import 'element-plus/es/components/loading/style/css'
const pinia = createPinia();
const app = createApp(App);
// 导入全局的i18n文件
const loadLocalMessages = async () => {
const messages: Record<string, any> = {};
@@ -55,7 +52,9 @@ const getLocalLang = () => {
const initApp = async () => {
const pinia = createPinia();
const app = createApp(App);
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 加载语言消息
const messages = await loadLocalMessages();
const elementLocale = getLocalLang() === "zh" ? zhCn : en;

View File

@@ -1,52 +0,0 @@
/**
* 登录相关 Mock 数据
*/
/**
* Mock 登录响应
*/
export const getMockLoginResponse = (username: string, password: string) => {
// 模拟登录验证
if (username && password) {
return {
code: 0,
data: {
accessToken: 'mock_access_token_' + Date.now(),
refreshToken: 'mock_refresh_token_' + Date.now(),
expiresIn: 7200, // 2小时
userInfo: {
id: 1,
username: username,
name: username === 'admin' ? '管理员' : '普通用户',
role: username === 'admin' ? 'admin' : 'user',
avatar: '',
},
},
msg: '登录成功',
}
} else {
return {
code: 400,
data: null,
msg: '用户名或密码不能为空',
}
}
}
/**
* Mock 用户信息响应
*/
export const getMockUserInfoResponse = () => {
return {
code: 0,
data: {
id: 1,
username: 'admin',
name: '管理员',
role: 'admin',
avatar: '',
},
msg: '获取用户信息成功',
}
}

View File

@@ -1,97 +0,0 @@
/**
* Mock 数据管理
* 用于开发阶段模拟后端接口返回的数据
*/
import { getMockMenuResponse } from './menu'
import { getMockLoginResponse, getMockUserInfoResponse } from './auth'
// 是否启用 Mock可以通过环境变量控制
export const ENABLE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' || import.meta.env.DEV
/**
* Mock API 响应映射
* key: API 路径
* value: 返回的 Mock 数据函数
*/
const mockApiMap: Record<string, (params?: any, data?: any) => any> = {
'/api/menus': () => getMockMenuResponse(),
'/api/auth/login': (_params?: any, data?: any) => {
return getMockLoginResponse(data?.username || '', data?.password || '')
},
'/api/user/info': () => getMockUserInfoResponse(),
// 可以在这里添加更多的 mock 接口
// '/api/user/list': getMockUserList,
}
/**
* 获取 Mock 数据
* @param url API 路径
* @param params 请求参数GET
* @param data 请求体POST/PUT
* @returns Mock 数据或 null
*/
export const getMockData = (url: string, params?: any, data?: any): any => {
if (!ENABLE_MOCK || !url) {
return null
}
// 精确匹配
if (mockApiMap[url]) {
return mockApiMap[url](params, data)
}
// 模糊匹配(支持带查询参数的 URL
const urlWithoutQuery = url.split('?')[0]
if (urlWithoutQuery && mockApiMap[urlWithoutQuery]) {
return mockApiMap[urlWithoutQuery](params, data)
}
return null
}
/**
* 检查是否应该使用 Mock 数据
* @param url API 路径
* @returns 是否应该使用 Mock
*/
export const shouldUseMock = (url: string): boolean => {
if (!ENABLE_MOCK || !url) {
return false
}
// 精确匹配
if (mockApiMap[url]) {
return true
}
// 模糊匹配(支持带查询参数的 URL
const urlWithoutQuery = url.split('?')[0]
return !!(urlWithoutQuery && mockApiMap[urlWithoutQuery])
}
/**
* 添加 Mock 接口
* @param url API 路径
* @param mockFn Mock 数据函数
*/
export const addMockApi = (url: string, mockFn: (params?: any, data?: any) => any) => {
mockApiMap[url] = mockFn
}
/**
* 移除 Mock 接口
* @param url API 路径
*/
export const removeMockApi = (url: string) => {
delete mockApiMap[url]
}
export default {
getMockData,
shouldUseMock,
addMockApi,
removeMockApi,
ENABLE_MOCK,
}

View File

@@ -296,6 +296,35 @@ export const mockMenuData: MockMenuRoute[] = [
},
];
export const mockBackendMenuData = [
{
"name": "字典管理",
"code": "dict",
"icon": "OfficeBuilding",
},
{
"name": "组织管理",
"code": "origanization",
"icon": "OfficeBuilding",
},
{
"name": "人员管理",
"code": "personnel",
"icon": "OfficeBuilding",
},
{
"name": "权限管理",
"code": "permission",
"icon": "OfficeBuilding",
},
{
"name": "流程管理",
"code": "flow",
"icon": "OfficeBuilding",
}
]
/**
* 获取 Mock 菜单数据的响应格式
* 模拟后端接口返回的数据结构

View File

@@ -0,0 +1,266 @@
$color-blue: #409eff;
$color-blue-bg: #f5f8ff;
$color-text-main: #303133;
$color-text-sub: #99a2aa;
$color-border: #e4e7ed;
$color-white: #fff;
.comment-app {
font-family: -apple-system, sans-serif;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
height: 100%;
// 评论的样式
.main-publisher{
position: absolute;
bottom: 0;
left: 0;
width: 100%;
z-index: 200;
}
.input-wrapper {
flex-shrink: 0;
border: 1px solid $color-border;
border-radius: 12px;
padding: 12px;
position: relative;
transition: border-color 0.2s;
background-color: $color-white;
&:focus-within {
border-color: $color-blue;
}
:deep(.el-textarea__inner) {
border: none;
padding: 0;
box-shadow: none;
font-size: 15px;
}
.input-tools {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 8px;
.left-icons {
display: flex;
gap: 5px;
.el-button {
color: $color-text-sub;
font-size: 18px;
padding: 4px;
}
.el-button + .el-button {
margin-left: 0;
}
}
.send-btn {
margin-left: 8px;
color: $color-blue;
}
}
}
// 2. 评论列表样式
.comment-list {
overflow-y: auto;
flex: 0 0 calc(100% - 120px);
.comment-group {
margin-bottom: 30px;
.parent-node {
display: flex;
gap: 12px;
.user-avatar {
background-color: $color-blue;
color: #fff;
font-weight: bold;
}
.node-main {
flex: 1;
.user-info {
display: flex;
align-items: center;
gap: 10px;
.nickname {
font-weight: bold;
color: $color-text-main;
}
.createTime {
font-size: 13px;
color: $color-text-sub;
}
}
.content {
margin: 8px 0;
font-size: 15px;
color: $color-text-main;
}
.actions {
display: flex;
margin-bottom: 12px;
.el-button {
font-size: 13px;
color: $color-text-sub;
display: flex;
align-items: center;
gap: 4px;
&:hover {
color: $color-blue;
}
}
.delete-btn:hover {
color: #f56c6c;
}
}
}
}
}
}
.comment-loading-status{
display: flex;
justify-content: center;
align-items: center;
padding: 5px 0;
}
// 3. 子评论卡片样式
.sub-container {
background-color: $color-blue-bg;
border-radius: 8px;
border-left: 3px solid #d9e5ff;
padding: 16px;
margin-top: 10px;
.sub-node {
display: flex;
gap: 10px;
margin-bottom: 15px;
&:last-child {
margin-bottom: 0;
}
.sub-main {
flex: 1;
.sub-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
.sub-user-info {
font-size: 14px;
.nickname {
font-weight: bold;
}
.reply-text {
margin: 0 6px;
color: $color-text-sub;
}
.target-name {
color: $color-blue;
font-weight: 500;
}
}
.createTime {
font-size: 12px;
color: $color-text-sub;
}
}
.content-body {
font-size: 14px;
color: $color-text-main;
margin-bottom: 3px;
}
}
}
// 展开收起样式
.sub-list-controls {
display: flex;
align-items: center;
padding-left: 46px; // 与头像对齐的偏移量
.expand-line {
width: 20px;
height: 1px;
background-color: #dcdfe6;
margin-right: 8px;
}
.el-button {
font-size: 13px;
color: $color-text-sub;
font-weight: 500;
&:hover {
color: $color-blue;
}
.el-icon {
margin-left: 4px;
transition: transform 0.3s;
}
}
}
}
:deep(.mention-highlight) {
background-color: rgba(64, 158, 255, 0.1);
color: #409eff;
padding: 2px 4px;
border-radius: 4px;
font-weight: 500;
margin: 0 2px;
cursor: pointer;
display: inline-block;
&:hover {
background-color: rgba(64, 158, 255, 0.2);
}
}
.observer-anchor{
height: 60px;
text-align: center;
font-size: 12px;
color: #808080;
}
}
// 评论组件骨架屏
.skeleton-comment-parent-node {
display: flex;
gap: 12px;
width: 100%;
.skeleton-avatar {
width: 36px;
height: 36px;
flex-shrink: 0;
}
.skeleton-node-main {
flex: 1;
.skeleton-user-info {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.skeleton-actions {
display: flex;
margin-bottom: 12px;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
import { ref, shallowRef } from "vue";
import { debounce } from "lodash-es";
interface SearchOptions {
debounceMs?: number;
}
export function useUserSearch(
apiFn: (keyword: string, signal: AbortSignal) => Promise<any[]>,
options: SearchOptions = {}
) {
const { debounceMs = 300 } = options;
const filteredUsers = ref<any[]>([]);
const selectedUsersCache = new Map<string, any[]>();
const searching = ref(false);
// 使用 shallowRef 存储 Controller因为它不需要深度响应
const abortController = shallowRef<AbortController | null>(null);
// 存储用户选中的缓存
const recordSelection = (user: any) => {
if (user && user.name) {
// 依然按名存数组,支持同名
const list = selectedUsersCache.get(user.name) || [];
if (!list.find((u) => u.id === user.id)) {
list.push(user);
selectedUsersCache.set(user.name, [...list]);
}
}
};
// 获取用户已选中的用户组
const getSelectedUsers = () => Array.from(selectedUsersCache.values()).flat();
// 清除用户选中的缓存
const clearSelection = () => {
selectedUsersCache.clear();
};
// 从缓存池中查找对应的数据
const getCacheByName = (name) => {
const list = selectedUsersCache.get(name);
return list ? [...list] : [];
};
/**
* 真正的请求逻辑
*/
const performSearch = async (keyword: string) => {
// 1. 中断之前的请求
if (abortController.value) {
abortController.value.abort();
}
// 2. 创建新的控制实例
abortController.value = new AbortController();
try {
// 3. 执行外部传入的 API
const res = await apiFn(keyword, abortController.value.signal);
filteredUsers.value = res;
} catch (err: any) {
// 仅处理非取消类的错误
if (err.name !== "AbortError" && err.message !== "canceled") {
console.error("Search API Error:", err);
filteredUsers.value = [];
}
} finally {
// 4. 如果当前请求没有被中断,则关闭 loading
if (!abortController.value?.signal.aborted) {
searching.value = false;
}
}
};
// 5. 创建防抖函数
const debouncedSearch = debounce(performSearch, debounceMs);
/**
* 暴露给外部调用的入口
*/
const search = (keyword: string) => {
// 如果输入框里没这个 @名字 了,从 selectedUsersCache 删掉它
for (const name of selectedUsersCache.keys()) {
if (!mainInput.value.includes(`@${name}`)) {
selectedUsersCache.delete(name);
}
}
searching.value = true;
debouncedSearch(keyword);
};
return {
filteredUsers,
searching,
selectedUsersCache,
recordSelection,
clearSelection,
getSelectedUsers,
getCacheByName,
search,
};
}

View File

@@ -0,0 +1,30 @@
/**
* @description
* @param {string} text 原始文本
* @param {string[]} atUsers 这条评论真正圈到的人员列表
*/
type atUserProps = {
id:string|number;
name?:string;
}
export const parseMention = (text: string, atUsers: atUserProps[]) => {
if (!text) return "";
if (!atUsers || atUsers.length === 0) {
return text.replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\n/g, '<br>');
}
// 只循环这条评论里【真正圈到】的人
const sortedMentions = [...atUsers].sort((a, b) => b.start - a.start);
let result = text;
sortedMentions.forEach((m) => {
const prefix = result.slice(0, m.start);
const suffix = result.slice(m.end);
const rawMentionText = result.slice(m.start, m.end);
const cleanName = rawMentionText.trim().replace(/^@+/, "");
const displayName = `@${cleanName}`;
const safeDisplayName = displayName.replace(/</g, "&lt;").replace(/>/g, "&gt;");
const highlight = `<span class="mention-highlight" data-user-id="${m.userId}">${safeDisplayName}</span>`;
result = prefix + highlight + suffix;
});
return result.replace(/\n/g, '<br>');
};

View File

@@ -12,7 +12,7 @@
{{ topTitle }}
</div>
<standardMenu
class="mj-aside_menu"
menu-class="mj-aside_menu"
:isCollapse="isCollapse"
:menuList="sideMenuList"
:active-menu="selectedActiveMenu"
@@ -21,24 +21,24 @@
</div>
<!-- 展开收缩左侧菜单按钮 -->
<div class="mj-collapse" @click="showCollapse">
<el-icon><component :is="isCollapse ? DArrowRight : DArrowLeft" /></el-icon>
<el-icon><component :is="isCollapse ? 'DArrowRight' : 'DArrowLeft'" /></el-icon>
</div>
</div>
</el-aside>
<el-container>
<el-header class="mj-header-content">
<!-- 左侧的菜单展示 -->
<!-- 顶部左侧的菜单展示 -->
<standardMenu
menu-class="mj-top_menu"
:menuList="topLevelMenuList"
mode="horizontal"
:active-menu="selectedTopMenu"
@menu-select="handleTopMenuSelect"
/>
<!-- 右侧用户的内容 -->
<rightMenuGroup @on-stage-manage="onStageManage" />
<!-- 顶部右侧用户的内容 -->
<rightMenuGroup @on-stage-manage="(path)=>handleTopMenuSelect(path)" />
</el-header>
<el-main>
<!-- <card-item :list="[1,2,3,4,5,6]"/> -->
<el-main class="mj-main-backend-content">
<router-view />
</el-main>
</el-container>
@@ -48,7 +48,6 @@
<script setup lang="ts">
import standardMenu from "@/components/standardMenu/index.vue";
import { useUserStore } from "@/store";
import { DArrowLeft, DArrowRight } from "@element-plus/icons-vue";
import rightMenuGroup from './rightMenuGroup.vue';
import companyLogo from '@/assets/images/logo.png';
defineOptions({
@@ -57,6 +56,7 @@ defineOptions({
const userStore = useUserStore();
const router = useRouter();
const route = useRoute();
// 响应式断点(小屏阈值,小于此值视为小屏)
const BREAKPOINT = 1024;
@@ -97,7 +97,15 @@ const menuList = computed(() => {
const topLevelMenuList = computed(() => {
return menuList.value.map((item) => {
const { children, ...rest } = item;
return rest;
const hasChildren = children && Array.isArray(children) && children.length > 0;
const enhancedMeta = {
...rest.meta,
disabled: !hasChildren
};
return {
...rest,
meta: enhancedMeta
};
}).filter(itv=>itv.name !== 'stage');
});
@@ -106,10 +114,7 @@ const backTitle = computed(()=>{
return menuList.value.find(itv=>itv.name === 'stage')?.meta?.title || '-';
})
// 后台管理点击获取列表
const onStageManage = () =>{
selectedTopMenu.value = '/stage';
}
const topTitle = computed(() => {
return (
@@ -167,6 +172,18 @@ const sideMenuList = computed(() => {
// 处理顶部菜单选中事件
const handleTopMenuSelect = (menuPath: string) => {
selectedTopMenu.value = menuPath;
const currentModule = menuList.value.find(item => item.path === menuPath);
if (currentModule && currentModule.children && currentModule.children.length > 0) {
const firstChild = currentModule.children[0];
const targetPath = firstChild.path.startsWith("/")
? firstChild.path
: `${currentModule.path}/${firstChild.path}`;
router.push(targetPath);
selectedActiveMenu.value = targetPath;
} else {
router.push(menuPath);
}
};
// 左侧菜单选中事件
@@ -174,23 +191,37 @@ const handleSideMenuSelect = (menuPath: string) => {
selectedActiveMenu.value = menuPath;
};
// 高亮当前激活的菜单
const activeMenuByUrl = () => {
// 1. 获取当前路由路径,例如 "/stage/dict" 或 "/business/customer"
const currentPath = route.path;
// 2. 尝试从 topLevelMenuList 中直接找匹配项
let matchedMenu = topLevelMenuList.value.find(menu =>
currentPath.startsWith(menu.path)
);
// 3. 如果没找到,且路径以 /stage 开头 那就是后台管理模块
if (!matchedMenu && currentPath.startsWith('/stage')) {
selectedTopMenu.value = '/stage';
}else{
// 4. 赋值选中的菜单
if (matchedMenu) {
selectedTopMenu.value = matchedMenu.path;
} else if (topLevelMenuList.value.length > 0) {
selectedTopMenu.value = topLevelMenuList.value[0].path;
}
}
};
watch(() => route.path, () => {
activeMenuByUrl();
}, { immediate: true });
// 初始化:默认选中第一个菜单
onMounted(() => {
const currentRoutePath = router.currentRoute.value.path;
const matchedTopMenu = topLevelMenuList.value.find(menu =>
currentRoutePath.startsWith(`${menu.path}/`) || currentRoutePath === menu.path
);
if (matchedTopMenu && matchedTopMenu.path) {
selectedTopMenu.value = matchedTopMenu.path;
} else if (topLevelMenuList.value.length > 0) {
// 否则默认选中第一个菜单
const firstMenu = topLevelMenuList.value[0];
if (firstMenu && firstMenu.path) {
selectedTopMenu.value = firstMenu.path;
}
}
activeMenuByUrl();
// 监听窗口大小变化
window.addEventListener("resize", handleResize);
@@ -219,6 +250,7 @@ onUnmounted(() => {
:deep(.el-main) {
--el-main-padding: 16px;
padding: var(--el-main-padding) calc(var(--el-main-padding) * 2);
background-color: #f8fafc;
}
@@ -249,7 +281,7 @@ onUnmounted(() => {
.mj-aside-title {
font-size: 10px;
color: #888;
padding: 10px var(--el-menu-base-level-padding);
padding: 10px calc(var(--el-menu-base-level-padding) + 16px);
transition: opacity 0.3s;
}
}

View File

@@ -16,7 +16,7 @@
<el-dropdown placement="bottom" trigger="click" @command="handleCommand">
<div class="user-info">
<div class="text-meta">
<span class="userinfo-username">{{ userInfo.username }}</span>
<span class="userinfo-username">{{ userInfo.nickname }}</span>
<span class="userinfo-role">SUPER ADMIN</span>
</div>
<name-avatar :name="userInfo.nickname" :src="userInfo.avatar" :size="30" />
@@ -48,7 +48,6 @@
</div>
</template>
<script setup lang="ts">
import { Monitor, Bell } from "@element-plus/icons-vue";
import TokenManager from "@/utils/storage";
import NameAvatar from "@/components/nameAvatar/index.vue";
import { useUserStore } from "@/store";
@@ -81,7 +80,7 @@ const handleCommand = (command: string) => {
};
const onStageManage = () => {
emits("on-stage-manage");
emits("on-stage-manage",'/stage');
};
// 获取当前的用户的数据信息

View File

@@ -74,7 +74,7 @@
</el-form>
<div class="form-footer">
<span>© 2025 视界保留所有权利</span>
<span>© 2025 服链保留所有权利</span>
<div class="links">
<el-link underline="never">隐私政策</el-link>
<el-link underline="never">服务条款</el-link>
@@ -87,10 +87,10 @@
<script setup lang="ts">
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { Message, Lock, Right } from "@element-plus/icons-vue";
import { login } from "@/api";
import { useUserStore } from "@/store";
import TokenManager from '@/utils/storage';
import { Lock,Message,Right } from '@element-plus/icons-vue';
defineOptions({ name: "Login" });
@@ -110,12 +110,13 @@ const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const tokenManager = TokenManager.getInstance();
const KEEP_KEY = "keep_login_remember";
const loginFormRef = ref<FormInstance>();
const loading = ref(false);
const loginForm = reactive({
username: "user",
password: "password",
username: "",
password: "",
remember:false
});
@@ -142,9 +143,10 @@ const handleLogin = async () => {
grant_type: 'password'
});
if (response) {
const { access_token, refresh_token, expires_in,username,userId,avatar } = response;
const { access_token, refresh_token, expires_in,username,userId,avatar,nickname } = response;
const userInfo = {
username,
nickname,
userId,
avatar
}
@@ -158,22 +160,46 @@ const handleLogin = async () => {
userStore.setToken(refresh_token);
// 获取用户信息
userStore.setUserInfo(userInfo);
// TODO:记住密码功能
// 记住密码功能
if (loginForm.remember) {
const saveInfo = {
username: loginForm.username,
password: loginForm.password // TODO:密码要加密才行
};
tokenManager.setToken(KEEP_KEY, JSON.stringify(saveInfo));
} else {
// 如果用户取消勾选,则移除
tokenManager.removeToken(KEEP_KEY);
}
ElMessage.success("登录成功");
// 跳转到首页或之前访问的页面
const redirect = (route.query.redirect as string) || "/";
router.push(redirect);
const redirect = (route.query.redirect as string);
router.push({path:'/',query:redirect});
}
} catch (error: any) {
console.error("Login error:", error);
ElMessage.error(error.msg || "登录失败,请稍后重试");
} finally {
loading.value = false;
}
});
};
onMounted(() => {
const savedInfo = tokenManager.getToken(KEEP_KEY);
if (savedInfo) {
try {
const { username, password } = JSON.parse(savedInfo);
loginForm.username = username;
loginForm.password = password;
loginForm.remember = true;
} catch (e) {
console.error("解析记住的密码失败", e);
}
}
});
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,13 @@
<template>
<div class="">
商机管理
</div>
</template>
<script setup lang="ts">
import {reactive,ref,onMounted} from "vue"
defineOptions({})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,72 @@
<template>
<div class="custom-filter-panel">
<div class="panel-header">
<span class="panel-title">条件筛选</span>
<el-button link type="primary" class="panel-reset_btn" @click="$emit('reset')">重置所有</el-button>
</div>
<div class="panel-body">
<slot></slot>
</div>
<div class="panel-footer">
<el-button class="cancel-btn" link @click="$emit('cancel')">取消</el-button>
<el-button type="primary" class="confirm-btn" @click="$emit('confirm')">确认筛选</el-button>
</div>
</div>
</template>
<script setup lang="ts">
defineEmits(["reset", "cancel", "confirm"]);
</script>
<style lang="scss" scoped>
.custom-filter-panel {
--bg-color: #fbfcfd;
--line-color:#F1F5F9;
background-color: #ffffff;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(0,0,0,.1);
.panel-header {
background-color: var(--bg-color);
padding: 11px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--line-color);
.panel-title {
font-size: 13px;
}
.panel-reset_btn{
font-size: 11px;
}
}
.panel-body {
background-color: #ffffff;
flex: 1;
overflow-y: auto;
min-height: 100px;
}
.panel-footer {
background-color: var(--bg-color);
padding: 5px 24px;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 6px;
border-top: 1px solid var(--line-color);
.cancel-btn {
color: #62748e;
&:hover{
color: #314158;
}
}
}
}
</style>

View File

@@ -0,0 +1,603 @@
<template>
<div class="customer-manage-container">
<!-- 顶部搜索 -->
<stageBreadcrumbs :show-title="false">
<template #title>
<div class="customer-flex customer-header-container">
<div class="customer-title">客户列表</div>
<div class="customer-num">8</div>
</div>
</template>
<template #content>
<el-button icon="Plus" type="primary">新增客户</el-button>
</template>
<template #action>
<div class="customer-flex customer-actions">
<div class="search-auto-expand-input">
<el-input
placeholder="搜索客户..."
class="auto-expand-input"
:prefix-icon="'Search'"
v-model="searchVal"
@keyup.enter="() => {}"
></el-input>
</div>
<!-- 查询条件 -->
<CommonFilter @on-hide="onMenuClose">
<FilterContainer>
<div class="filter-container">
<el-form label-position="top" class="custom-form">
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="客户分类">
<el-radio-group v-model="form.type" class="tag-group">
<el-radio-button label="潜在客户" />
<el-radio-button label="合作客户" />
<el-radio-button label="战略客户" />
</el-radio-group>
</el-form-item>
<el-form-item label="客户标签">
<el-radio-group v-model="form.tag" class="tag-group">
<el-radio-button label="KA" />
<el-radio-button label="SMB" />
<el-radio-button label="NA" />
</el-radio-group>
</el-form-item>
<el-form-item label="客户所在省/市">
<el-select
v-model="form.region"
placeholder="请选择地区"
class="full-width"
>
<el-option label="上海" value="shanghai" />
<el-option label="北京" value="beijing" />
</el-select>
</el-form-item>
<el-form-item label="增值税税率">
<el-radio-group
v-model="form.taxRate"
class="tag-group"
>
<el-radio-button label="6%" />
<el-radio-button label="3%" />
<el-radio-button label="1%" />
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="客户来源">
<el-radio-group v-model="form.source" class="tag-group">
<el-radio-button label="公司渠道" />
<el-radio-button label="商务自拓" />
<el-radio-button label="转介绍" />
</el-radio-group>
</el-form-item>
<el-form-item label="游戏负责人">
<el-select
v-model="form.manager"
placeholder="请选择负责人"
class="full-width"
>
<el-option label="张三" value="1" />
</el-select>
</el-form-item>
<el-form-item label="税种">
<el-select
v-model="form.taxType"
placeholder="请选择税种"
class="full-width"
>
<el-option label="增值税" value="vat" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
</FilterContainer>
</CommonFilter>
<ViewSwitcher v-model="currentView" />
</div>
</template>
</stageBreadcrumbs>
<template v-if="currentView === 'grid'">
<CardManager
:fetch-api="apiGetCustomerList"
:pageSize="20"
:itemHeight="350"
:columnCount="cols"
:extraParams="searchForm"
@on-action="handleAction"
ref="managerRef"
>
<template #default="{ item, openMenu }">
<!-- 底部卡片列表 -->
<cardItem :item="item">
<template #content>
<!-- 顶部公司名称 -->
<div class="customer-flex customer-company">
<div class="customer-icon">
<name-avatar
:size="45"
:name="item.title"
shape="square"
></name-avatar>
</div>
<div class="customer-info">
<div class="mj-ellipsis-one-line company-name">
{{ item.title }}
</div>
<div class="company-num">{{ item.num }}</div>
<!-- 更多按钮 -->
<div
class="customer-info-more"
@click.stop="openMenu($event, item)"
>
<el-icon><MoreFilled /></el-icon>
</div>
</div>
</div>
<!-- 类型 -->
<div class="customer-flex customer-category">
<div
class="customer-category-item"
:key="cateIndex"
v-for="(cate, cateIndex) in item.category"
>
{{ cate }}
</div>
</div>
<!-- 相关业务指标 -->
<div class="customer-flex customer-indicators">
<div
class="customer-indicators-item"
v-for="(row, rowIndex) in item.indicators"
:key="rowIndex"
>
<div class="indicators-item-title">{{ row.title }}</div>
<div class="indicators-item-num">{{ row.num }}</div>
</div>
</div>
<!-- 底部地址 -->
<div class="customer-flex customer-bottom-address">
<div class="customer-bottom-address_icon">
<el-icon><Location /></el-icon>
</div>
<div class="customer-bottom-address_text">
<AutoTooltip :content="item.address" />
</div>
</div>
</template>
</cardItem>
</template>
</CardManager>
</template>
<!-- 底部Table列表 -->
<template v-else>
<CommonTable
ref="tableRef"
:columns="columns"
selection
:request-api="apiGetCustomerList"
/>
</template>
<!-- 查看更多虚拟触发 -->
<ActionMenu
:visible="visible"
:style="menuStyle"
:data="[]"
:bodyStyle="bodyStyle"
@close="closeMenu"
@action="handleMenuClick"
/>
</div>
</template>
<script setup lang="ts">
import { h } from "vue";
import { useWindowSize } from "@vueuse/core";
import collapseHeader from "@/components/collapseHeader/index.vue";
import cardItem from "@/components/cardManager/cardItem.vue";
import cardManager from "@/components/cardManager/index.vue";
import nameAvatar from "@/components/nameAvatar/index.vue";
import AutoTooltip from "@/components/autoTooltip/index.vue";
import ActionMenu from "@/components/popoverMenu/index.vue";
import stageBreadcrumbs from "@/components/stageBreadcrumbs/index.vue";
import CommonFilter from "@/components/commonFilter/index.vue";
import ViewSwitcher from "@/components/viewSwitcher/index.vue";
import { useTableAction } from "@/hooks/useTableAction";
import CommonTable from "@/components/proTable/proTablev2.vue";
import FilterContainer from "./filterContainer.vue";
defineOptions({ name: "Customer" });
const { width } = useWindowSize();
const cols = computed(() => {
if (width.value > 1400) return 5;
if (width.value > 1100) return 4;
if (width.value > 768) return 2;
return 1;
});
const form = ref({
type: [],
source: [],
tags: [],
manager: "",
taxRate: "",
});
const handleConfirm = () => {
console.log("提交筛选:", form.value);
};
const searchVal = ref("");
const currentView = ref("grid");
const tableRef = ref(null);
const { handleAction, handleDelete } = useTableAction(tableRef);
const bodyStyle = {
backgroundColor: "#fff",
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
border: "1px solid #e4e7ed",
};
const cardList = ref([]);
const apiGetCustomerList = async (params) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
records: [
{
id: 1,
title: "腾讯科技(深圳)有限公司",
num: "KH-202403001",
category: ["重要客户", "全电专票", "6%"],
indicators: [
{
title: "工作室",
num: 1,
},
{
title: "项目",
num: 4,
},
{
title: "合同",
num: 3,
},
],
address: "杭州市滨江区网商路599号",
},
{
id: 2,
title: "腾讯科技(深圳)有限公司",
num: "KH-202403001",
category: ["重要客户", "全电专票", "6%"],
indicators: [
{
title: "工作室",
num: 1,
},
{
title: "项目",
num: 4,
},
{
title: "合同",
num: 3,
},
],
address: "杭州市滨江区网商路599号",
},
],
};
};
const columns = [
{
prop: "id",
label: "线索名称/编号",
align: "center",
},
{
prop: "name",
label: "客户名称",
align: "center",
},
{
prop: "p1",
label: "工作室名称",
align: "center",
},
{
prop: "p2",
label: "游戏名称",
align: "center",
},
{
prop: "p3",
label: "线索跟进人",
align: "center",
},
{
prop: "p4",
label: "线索状态",
align: "center",
},
{
prop: "p5",
label: "创建时间",
align: "center",
valueType: "date",
format: "YYYY-MM-DD HH:mm",
},
{
prop: "actions",
label: "操作",
align: "right",
width: "300",
actions: [],
},
];
const handleMenuClick = (type: string, data: Customer) => {
console.log(`执行操作: ${type}`, data.name);
};
// 当前关闭事件
const onMenuClose = () => {
console.log("执行当前的关闭事件");
};
</script>
<style lang="scss" scoped>
.customer-manage-container {
.customer-flex {
display: flex;
align-items: center;
}
.customer-actions {
gap: 10px;
:deep(.search-auto-expand-input .auto-expand-input) {
--el-input-height: 38px;
--el-input-border-radius: 10px;
}
}
:deep(.stage-breadcrumbs) {
border-bottom: none;
}
.customer-header-container {
gap: 8px;
.customer-title {
font-size: 15px;
color: #0f172b;
font-weight: 600;
}
.customer-num {
padding: 2px 8px;
border-radius: 6px;
background-color: #f1f5f9;
color: #62748e;
box-sizing: border-box;
font-size: 12px;
}
}
:deep(.mj-icon-container) {
--radius: 10px;
--border-color: #e2e8f0;
--shadow: inset 0 0 1px rgba(0, 0, 0, 0.06);
--hover-color: #eff6ff;
}
.customer-company {
gap: 8px;
margin-bottom: 16px;
:deep(.mj-name-avatar) {
--el-avatar-border-radius: 10px;
}
.customer-info {
font-size: 14px;
color: #000;
min-width: 0;
position: relative;
flex: 1;
padding-right: 28px;
.company-name {
&:hover {
color: var(--el-color-primary);
}
}
.company-num {
font-size: 10px;
padding: 2px 6px;
box-sizing: border-box;
border-radius: 4px;
border: 1px solid #f1f5f9;
background-color: #f8fafc;
display: inline-block;
color: #90a1b9;
margin-top: 8px;
}
.customer-info-more {
display: inline-block;
transform: rotate(90deg);
position: absolute;
right: 0;
top: 0;
width: 28px;
height: 28px;
line-height: 28px;
text-align: center;
border-radius: 10px;
transition: background-color 0.3s ease;
&:hover {
background-color: #f1f5f9;
}
}
}
}
.customer-category {
gap: 6px;
margin-bottom: 16px;
.customer-category-item {
padding: 2px 8px;
display: inline-block;
border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
font-size: 10px;
&:nth-child(1) {
color: #9810fa;
background-color: #faf5ff;
}
&:nth-child(2) {
color: #6c788e;
background-color: #f8fafc;
}
&:nth-child(3) {
color: #ffffff;
background-color: #155dfc;
}
}
}
.customer-indicators {
gap: 6px;
margin-bottom: 16px;
.customer-indicators-item {
flex: 1;
min-height: 50px;
background-color: #fbfcfd;
border: 1px solid #f1f5f9;
box-sizing: border-box;
padding: 8px;
border-radius: 4px;
.indicators-item-title {
font-size: 8px;
color: #9da7c4;
}
.indicators-item-num {
font-size: 13px;
}
}
}
.customer-bottom-address {
border-top: 1px solid #f8fafc;
font-size: 12px;
padding-top: 12px;
gap: 10px;
color: #94a0b3;
.customer-bottom-address_icon {
border-radius: 50%;
background-color: #f8fafc;
box-sizing: border-box;
width: 20px;
height: 20px;
line-height: 20px;
text-align: center;
.el-icon {
vertical-align: middle;
}
}
.customer-bottom-address_text {
min-width: 0;
}
}
}
/* 使用类名嵌套提升权重,无需 !important */
.custom-filter-panel {
// 1. 表单 Label 样式:浅灰色,加深间距
.el-form-item__label {
color: #86909c;
font-size: 14px;
font-weight: 500;
margin-bottom: 10px;
padding: 0;
}
// 2. Select 选择器:还原浅灰背景和圆角
.el-select {
.el-select__wrapper {
background-color: #f2f3f5;
border-radius: 10px;
box-shadow: none; // 移除默认投影
height: 42px;
&.is-focused {
box-shadow: 0 0 0 1px #165dff inset; // 聚焦时显示边框
}
}
}
}
.filter-container {
padding: 24px;
background-color: #fff;
max-width: 800px;
// 1. 标题样式优化
:deep(.el-form-item__label) {
color: #909399; // 浅灰色文字
font-weight: 500;
margin-bottom: 12px;
line-height: 1;
}
// 2. 按钮组样式重写 (变成图中的独立方块效果)
.tag-group {
display: flex;
gap: 12px; // 间距
:deep(.el-radio-button) {
// 隐藏原生边框和阴影
.el-radio-button__inner {
border-radius: 8px;
background: transparent;
color: #606266;
padding: 8px 20px;
box-shadow: none;
transition: all 0.3s;
font-size: 11px;
}
// 选中状态
&.is-active .el-radio-button__inner {
background-color: #ecf5ff;
color: #409eff;
border-color: #b3d8ff;
}
// 悬停效果
&:hover:not(.is-active) .el-radio-button__inner {
color: #409eff;
border-color: #c6e2ff;
}
}
}
// 3. 输入框/选择框圆角及背景
.full-width {
width: 100%;
:deep(.el-input__wrapper) {
border-radius: 12px; // 更大的圆角
background-color: #f5f7fa; // 极浅灰背景
box-shadow: none; // 去除阴影边框
border: 1px solid transparent;
&.is-focus {
border-color: #409eff;
background-color: #fff;
}
}
}
:deep(.el-form-item) {
margin-bottom: 24px;
}
}
</style>

View File

@@ -0,0 +1,13 @@
<template>
<div class="">
游戏和工作室
</div>
</template>
<script setup lang="ts">
import {reactive,ref,onMounted} from "vue"
defineOptions({})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -13,13 +13,13 @@
<div class="mj-drawer-top-container">
<div class="top-toolbar">
<div class="left-actions">
<div class="search-dict-input">
<div class="search-auto-expand-input">
<el-input
v-model="searchQuery"
placeholder="搜索字段名称..."
class="custom-search-input auto-expand-input"
:prefix-icon="Search"
@keyup.enter="onConfirmSuccess"
@keyup.enter="onSearchData"
/>
</div>
<!-- 状态筛选的内容 -->
@@ -55,7 +55,7 @@
<el-option
:label="item.label"
:value="item.value"
v-for="(item, index) in DictManage.statusOptions"
v-for="(item, index) in dicts.permission_list_enable_disable"
:key="index"
/>
</el-select>
@@ -64,7 +64,7 @@
<el-button
type="primary"
class="apply-btn"
@click="onConfirmSuccess"
@click="onSearchData"
>应用筛选</el-button
>
</div>
@@ -86,43 +86,9 @@
<!-- Table列表 -->
<CommonTable
ref="tableRef"
v-model:data="list"
:columns="columns"
pagination
height="calc(100vh - 186px)"
:immediate="false"
:request-api="fetchData"
>
<!-- 名称点击 -->
<template #labelName="{ row }">
<el-button link type="primary" @click="onLevelNext(row)" v-if="!hasChild">{{
row.label
}}</el-button>
<span v-else>{{ row.label }}</span>
</template>
<!-- 状态插槽 -->
<template #status="{ row }">
<div
class="mj-status-dot"
:style="{
'--data-status-color': DictManage.statusDictColor[row.status],
}"
@click="handleDictStatus(row)"
>
{{ DictManage.statusDict[row.status] }}
</div>
</template>
<!-- <template #actions="{ row }">
<el-button link type="primary" v-if="!hasChild" @click="handleAddNext(row)"
>添加二级字段</el-button>
<el-button link type="primary" @click="handleEdit(row)"
>编辑</el-button
>
<el-button link type="danger" @click="handleDelete(row)"
>删除</el-button
>
</template> -->
</CommonTable>
<!-- 新增字段 -->
<dictFieldLevelManage
@@ -143,11 +109,16 @@
</el-drawer>
</template>
<script setup lang="ts">
import CommonTable from "@/components/proTable/index.vue";
import { h } from "vue";
import CommonTable from "@/components/proTable/proTablev2.vue";
import dayjs from "dayjs";
import { Search, Filter, Plus } from "@element-plus/icons-vue";
import dictFieldLevelManage from "./dictFieldLevelManage.vue";
import { DictManage } from "@/dict";
import { useTableAction } from "@/hooks/useTableAction";
import { useDict } from "@/hooks/useDictData";
// import { formatIndex } from "@/utils/utils";
const { dicts, refresh } = useDict("permission_list_enable_disable");
import {
getDictTypeValue,
deleteDictTypeValue,
@@ -166,36 +137,49 @@ const filterForm = reactive({
});
const size = ref<string>(""); //抽屉大小
const tableRef = ref(null);
const { handleAction, handleDelete: runDelete } = useTableAction(tableRef);
const visible = ref<boolean>(false);
const parentId = ref<string>("");
const total = ref(0);
const list = ref([]);
const hasChild = ref<boolean>(false); //是否是子级弹窗
const childId = ref<string|number>(''); // 子集的id
const childId = ref<string | number>(""); // 子集的id
const childModals = ref([]); //子弹窗的列表
const childModalRefs = ref({}); // 子弹窗的引用
const onCloseCallback = ref<Function | null>(null); // 关闭回调函数
const columns = computed(()=>[
const columns = computed(() => [
{
prop: "id",
label: "字典编码",
align:'center',
label: "字典编码"
},
{
prop: "label",
label: "字典名称",
align: "center",
slot: "labelName",
valueType: "ellipsis",
render: ({ rowData }: any) => {
if (hasChild.value) {
return h("span", { class: "v2-cell-text" }, rowData.label);
}
return h(ElButton, { type: "primary", link:true,onClick:()=>{
onLevelNext(rowData);
} }, () => rowData.label);
}
},
{
prop: "value",
label: "字典值",
showOverflowTooltip: true,
align: "center",
},
{
prop: "status",
label: "状态",
align: "center",
slot: "status",
valueType: "status",
options: computed(() => dicts.value.permission_list_enable_disable),
onClick: ({ cellValue, rowData }) => {
handleDictStatus(rowData);
},
},
{
prop: "sort",
@@ -206,47 +190,42 @@ const columns = computed(()=>[
prop: "updateTime",
label: "更新时间",
align: "center",
showOverflowTooltip: true,
width:200,
formatter: (val) => {
return val.updateTime
? dayjs(val.updateTime).format("YYYY-MM-DD HH:mm")
: "-";
},
valueType: "date",
format: "YYYY-MM-DD HH:mm",
},
{
prop: "actions",
label: "操作",
align: "right",
width: "300",
actions:[
{
prop: "actions",
label: "操作",
align: "right",
width: 300,
actions: [
{
label: "添加二级字段",
type: "primary",
link:true,
link: true,
permission: ["edit"],
show:()=>{
return !hasChild.value
show: () => {
return !hasChild.value;
},
onClick: (row) => handleAddNext(row),
},
{
label: "编辑",
type: "primary",
link:true,
link: true,
permission: ["edit"],
onClick: (row) => handleEdit(row),
},
{
label: "删除",
type: "danger",
link:true,
link: true,
permission: ["delete"],
onClick: (row) => handleDelete(row),
},
]
],
},
])
]);
// 设置子弹窗引用
const setChildModalRef = (el, key) => {
@@ -256,7 +235,9 @@ const setChildModalRef = (el, key) => {
};
// 点击获取二级菜单数据
const onLevelNext = (row) => {
const childKey = `child-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const childKey = `child-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`;
childModals.value.push({
key: childKey,
data: row,
@@ -266,11 +247,11 @@ const onLevelNext = (row) => {
nextTick(() => {
const childRef = childModalRefs.value[childKey];
if (childRef) {
childRef.open({
childRef.open({
...row,
parentId:parentId.value,
hasChild: true,
onClose: () => removeChildModal(childKey)
parentId: parentId.value,
hasChild: true,
onClose: () => removeChildModal(childKey),
});
}
});
@@ -278,7 +259,7 @@ const onLevelNext = (row) => {
// 移除当前组件
const removeChildModal = (key: string) => {
const index = childModals.value.findIndex(child => child.key === key);
const index = childModals.value.findIndex((child) => child.key === key);
if (index !== -1) {
childModals.value.splice(index, 1);
}
@@ -293,9 +274,11 @@ const fetchData = async (params) => {
...params,
keyword: searchQuery.value,
...filterForm,
}
};
const response = hasChild.value ? await getNextDictMenu(parentId.value,childId.value,queryParams) : await getDictTypeValue(parentId.value, queryParams);
const response = hasChild.value
? await getNextDictMenu(parentId.value, childId.value, queryParams)
: await getDictTypeValue(parentId.value, queryParams);
return response;
} catch (error) {
console.log("getTableData Error", error);
@@ -319,20 +302,27 @@ const handleDictStatus = async (row) => {
const addFields = () => {
dictTitle.value = "新增字段";
addVisible.value = true;
Object.assign(selectItem,{
id:null,
parentId:null,
Object.assign(selectItem, {
id: null,
parentId: null,
label: "",
value: "",
sort: 0,
status:1,
remark:''
})
status: 1,
remark: "",
});
};
// 确定刷新数据
const onConfirmSuccess = () => {
tableRef.value && tableRef.value.refresh();
if (selectItem.id) {
handleAction(Promise.resolve(true), selectItem.id, getTableData, {
showMsg: false,
silentUpdate: true,
});
} else {
tableRef.value?.refresh();
}
};
// 关闭popover 重置数据信息
@@ -340,17 +330,22 @@ const onCloseFilter = () => {
filterForm.status = "";
};
// 筛选
const onSearchData = () =>{
tableRef.value?.refresh();
}
// 筛选重置
const onReset = () => {
onCloseFilter();
onConfirmSuccess();
onSearchData();
};
// 添加二级字段
const handleAddNext = async (item) => {
addVisible.value = true;
dictTitle.value = "添加二级字段";
Object.assign(selectItem,{},{parentId:item.id});
Object.assign(selectItem, {}, { parentId: item.id });
};
// 编辑当前字段
const handleEdit = (item) => {
@@ -366,6 +361,8 @@ const handleDelete = async (item) => {
try {
await deleteDictTypeValue(parentId.value, item.id);
tableRef.value && tableRef.value.refresh();
await runDelete();
} catch (error) {
console.log("fetch error", error);
}
@@ -382,7 +379,7 @@ defineExpose({
hasChild.value = item.hasChild ?? false;
// 处理子集的弹窗
if (hasChild.value) {
size.value = "60%";
size.value = "65%";
childId.value = item.id;
if (item.onClose) {
onCloseCallback.value = item.onClose;
@@ -390,7 +387,7 @@ defineExpose({
}
await nextTick();
if (tableRef.value) {
await tableRef.value.refresh();
await tableRef.value.updateSize();
}
},
close() {

View File

@@ -9,6 +9,7 @@
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="false"
modal-class="standard-overlay-dialog-flat"
>
<el-form
ref="ruleFormRef"
@@ -17,10 +18,17 @@
label-width="120px"
>
<el-form-item label="字段名称:" prop="label">
<el-input placeholder="请输入字典名称" v-model="form.label"></el-input>
<el-input
placeholder="请输入字典名称"
v-model="form.label"
></el-input>
</el-form-item>
<el-form-item label="字典值:" prop="value">
<el-input placeholder="请输入字典值" v-model="form.value" :disabled="form.id ? true : false"></el-input>
<el-input
placeholder="请输入字典值"
v-model="form.value"
:disabled="form.id ? true : false"
></el-input>
</el-form-item>
<el-form-item prop="sort">
<template #label>
@@ -33,12 +41,21 @@
</div>
</template>
<!-- 换成排序的输入框 -->
<el-input-number placeholder="请输入排序" v-model="form.sort" :min="0" controls-position="right"></el-input-number>
<el-input-number
placeholder="请输入排序"
v-model="form.sort"
:min="0"
controls-position="right"
></el-input-number>
</el-form-item>
<el-form-item label="状态:" prop="status">
<el-radio-group v-model="form.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">停用</el-radio>
<el-radio
:value="item.value"
v-for="(item, index) in dicts.permission_list_enable_disable"
:key="item.value"
>{{ item.label }}</el-radio
>
</el-radio-group>
</el-form-item>
<el-form-item label="备注:" prop="remark">
@@ -54,7 +71,12 @@
</el-form>
<template #footer>
<el-button @click="onCancel">取消</el-button>
<el-button @click="onConfirm(ruleFormRef)" type="primary" :loading="loading">确认</el-button>
<el-button
@click="onConfirm(ruleFormRef)"
type="primary"
:loading="loading"
>确认</el-button
>
</template>
</el-dialog>
</div>
@@ -62,8 +84,9 @@
<script setup lang="ts">
import { reactive, ref, onMounted } from "vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { QuestionFilled } from "@element-plus/icons-vue";
import { saveDictTypeValue, updateDictTypeValue } from "@/api/stage/dict";
import { useDict } from "@/hooks/useDictData";
const { dicts, refresh } = useDict("permission_list_enable_disable");
defineOptions({ name: "DictFieldLevelManage" });
const loading = ref(false);
const {
@@ -74,12 +97,10 @@ const {
} = defineProps<{
dialogVisible: boolean;
row?: any;
parentId?: string|number;
parentId?: string | number;
title?: string;
}>();
const emit = defineEmits<{
(e: "update:dialogVisible", value: boolean): void;
(e: "confirm-success"): void;
@@ -90,22 +111,26 @@ const form = reactive({
label: "",
value: "",
sort: 0,
status:1,
remark:''
status: '1',
remark: "",
});
// 监听组件中传递的数据-然后进行复制操作
watch(()=>row,(newRow)=>{
if (newRow && Object.keys(newRow).length > 0) {
Object.assign(form, newRow);
watch(
() => row,
(newRow) => {
if (newRow && Object.keys(newRow).length > 0) {
Object.assign(form, newRow,{status:String(newRow.status)});
}
},{deep:true})
},
{ deep: true }
);
const rules = reactive({
label: [{ required: true, message: "请输入字段名称", trigger: "blur" }],
value: [{ required: true, message: "请输入字典值", trigger: "blur" }],
sort: [{ required: true, message: "请输入排序", trigger: "blur" }],
remark:[{ required: false, message: "请输入备注", trigger: "blur" }]
remark: [{ required: false, message: "请输入备注", trigger: "blur" }],
});
// 确定
@@ -114,16 +139,18 @@ const onConfirm = async (formEl: FormInstance | undefined) => {
await formEl.validate(async (valid, fields) => {
if (valid) {
loading.value = true;
console.log("获取外部的数据信息:",form,parentId)
console.log("获取外部的数据信息:", form, parentId);
// 如果是一级新增字段就是parentId为0 如果是添加二级字段就是parentId为父级的id
try {
const response = row.id ? await updateDictTypeValue(parentId,row.id,form) : await saveDictTypeValue(parentId,form);
ElMessage.success(row.id ? '修改成功' : '新增成功');
const response = row.id
? await updateDictTypeValue(parentId, row.id, form)
: await saveDictTypeValue(parentId, form);
ElMessage.success(row.id ? "修改成功" : "新增成功");
onCancel();
emit('confirm-success');
emit("confirm-success");
} catch (error) {
console.log('error',error);
} finally{
console.log("error", error);
} finally {
loading.value = false;
}
} else {

View File

@@ -9,6 +9,7 @@
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="false"
modal-class="standard-overlay-dialog-flat"
>
<el-form
ref="ruleFormRef"
@@ -33,8 +34,12 @@
</el-form-item>
<el-form-item label="状态:" prop="status">
<el-radio-group v-model="form.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">停用</el-radio>
<el-radio
:value="item.value"
v-for="(item, index) in dicts.permission_list_enable_disable"
:key="item.value"
>{{ item.label }}</el-radio
>
</el-radio-group>
</el-form-item>
<el-form-item label="备注:">
@@ -58,8 +63,9 @@
<script setup lang="ts">
import { reactive, ref, onMounted } from "vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { QuestionFilled } from "@element-plus/icons-vue";
import { addDictValue, updateDictValue } from "@/api/stage/dict";
import { useDict } from "@/hooks/useDictData";
const { dicts, refresh } = useDict("permission_list_enable_disable");
defineOptions({ name: "DictManage" });
const loading = ref(false);
const {
@@ -80,14 +86,14 @@ const ruleFormRef = ref<FormInstance>();
const form = reactive({
name: "",
key: "",
status: 1,
status: '1',
remark: "",
});
// 监听组件中传递的数据-然后进行复制操作
watch(()=>row,(newRow)=>{
if (newRow && Object.keys(newRow).length > 0) {
Object.assign(form, newRow);
Object.assign(form, newRow,{status:String(newRow.status)});
}
},{deep:true})
@@ -109,7 +115,7 @@ const onConfirm = async (formEl: FormInstance | undefined) => {
onCancel();
emit('confirm-success');
} catch (error) {
console.log('error',error);
console.log('error',row.id);
} finally{
loading.value = false;
}

View File

@@ -2,7 +2,7 @@
<div class="mj-dict">
<stageBreadcrumbs title="组织管理">
<template #content>
<el-button :icon="Plus" type="primary" @click="addDict"
<el-button :icon="'Plus'" type="primary" @click="addDict"
>新增字典</el-button
>
</template>
@@ -33,7 +33,9 @@
<el-option
:label="item.label"
:value="item.value"
v-for="(item, index) in DictManage.statusOptions"
v-for="(
item, index
) in dicts.permission_list_enable_disable"
:key="index"
/>
</el-select>
@@ -47,11 +49,11 @@
>
</div>
</CommonFilter>
<div class="search-dict-input">
<div class="search-auto-expand-input">
<el-input
placeholder="搜索字典..."
class="auto-expand-input"
:prefix-icon="Search"
:prefix-icon="'Search'"
v-model="searchVal"
@keyup.enter="fetchTableData"
></el-input>
@@ -64,48 +66,12 @@
<CommonTable
ref="dictTableRef"
:columns="columns"
v-model:data="dataValue"
v-model:total="total"
pagination
:request-api="getTableData"
>
<!-- 编号内容显示 -->
<template #number="{ row, index }">
<span>#{{ formatIndex(index) }}</span>
</template>
<template #name="{ row }">
<span style="font-weight: 600">{{ row.name }}</span>
</template>
<!-- 编码标识 -->
<template #code="{ row }">
<el-tag size="small" type="info">{{ row.key }}</el-tag>
</template>
<!-- 状态插槽 -->
<template #status="{ row }">
<div
class="mj-status-dot"
:style="{
'--data-status-color': DictManage.statusDictColor[row.status],
}"
@click="handleDictStatus(row)"
>
{{ DictManage.statusDict[row.status] }}
</div>
</template>
<!-- <template #actions="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="primary" @click="handlefieldsConfig(row)"
>字段配置</el-button
>
<el-button link type="danger" @click="handleDelete(row)"
>删除</el-button
>
</template> -->
</CommonTable>
<!-- 新增-编辑字典弹窗 -->
<dict-manage
<dict-manage-modules
v-model:dictVisible="dictVisible"
:row="selectItem"
@confirm-success="onConfirmSuccess"
@@ -116,10 +82,10 @@
</div>
</template>
<script setup lang="ts">
import { Plus, Search } from "@element-plus/icons-vue";
import CommonTable from "@/components/proTable/index.vue";
import { h } from "vue";
import CommonTable from "@/components/proTable/proTablev2.vue";
import dictFieldConfig from "./dictFieldConfig.vue";
import dictManage from "./dictManage.vue";
import dictManageModules from "./dictManage.vue";
import dayjs from "dayjs";
import {
getDictValues,
@@ -130,13 +96,17 @@ import {
import { DictManage } from "@/dict";
import { formatIndex } from "@/utils/utils";
import { ElMessage } from "element-plus";
import { useTableAction } from "@/hooks/useTableAction";
import { useDict } from "@/hooks/useDictData";
const { dicts, refresh } = useDict("permission_list_enable_disable");
defineOptions({ name: "Dictionary" });
const fieldsConfigRef = ref(null);
const dictTableRef = ref(null);
const { handleAction, handleDelete: runDelete } = useTableAction(dictTableRef);
const dictVisible = ref<boolean>(false);
const searchVal = ref<string>("");
const total = ref<number>(0);
const filterForm = reactive({
status: "",
@@ -146,41 +116,63 @@ const selectItem = reactive({});
// 列表columns数据
const columns = [
{ prop: "id", label: "编号", width: "80", align: "center", slot: "number" },
{ prop: "name", label: "字典名称", align: "center", slot: "name" },
{
prop: "id",
label: "编号",
width: "80",
align: "center",
render: ({ rowData, rowIndex }) => {
return h("span", `#${formatIndex(rowIndex)}`);
},
},
{
prop: "name",
label: "字典名称",
align: "center",
render: ({ rowData }: any) => {
return h("span", { style: { fontWeight: "600" } }, rowData.name);
},
},
{
prop: "key",
label: "编码标识",
align: "center",
slot: "code",
width: 300,
render: ({ rowData }: any) => {
return h(ElTag, { size: "small", type: "info" }, () => rowData.key);
},
},
{
prop: "status",
label: "状态",
align: "center",
slot: "status",
width: 150,
valueType: "status",
options: computed(() => dicts.value.permission_list_enable_disable),
onClick: ({ cellValue, rowData }) => {
handleDictStatus(rowData);
},
},
{
prop: "remark",
label: "备注说明",
align: "center",
showOverflowTooltip: true,
valueType: "ellipsis",
width: 150,
},
{
prop: "createTime",
label: "创建时间",
align: "center",
showOverflowTooltip: true,
formatter: (val) => {
return val.createTime
? dayjs(val.createTime).format("YYYY-MM-DD HH:mm")
: "-";
},
valueType: "date",
format: "YYYY-MM-DD HH:mm",
width: 150,
},
{
prop: "updateByName",
label: "最后修改人",
align: "center",
width: 150,
},
{
prop: "actions",
@@ -209,13 +201,10 @@ const columns = [
permission: ["delete"],
onClick: (row) => handleDelete(row),
}
],
]
},
];
// 返回的data数据信息
const dataValue = ref([]);
// popover关闭事件
const onPopoverHide = () => {
filterForm.status = "";
@@ -230,8 +219,8 @@ const getTableData = async (params) => {
try {
const response = await getDictValues({
...params,
keyword: searchVal.value,
...filterForm,
...(searchVal.value && { keyword: searchVal.value }),
...(filterForm.status && { status: filterForm.status }),
});
return response;
} catch (error) {
@@ -240,10 +229,16 @@ const getTableData = async (params) => {
};
// 搜索查询条件信息
const fetchTableData = () => {
dictTableRef.value && dictTableRef.value.refresh();
onConfirmSuccess();
};
const clearSelectItem = () => {
Object.assign(selectItem,{id:null,name:'',key:'',status:1,remark:''})
Object.assign(selectItem, {
id: null,
name: "",
key: "",
status: 1,
remark: "",
});
};
// 新增字典信息
const addDict = () => {
@@ -259,19 +254,29 @@ const handleEdit = (item) => {
// 启用-禁用事件
const handleDictStatus = async (row) => {
try {
row.status === 1 ? await disableDict(row.id) : await enableDict(row.id);
ElMessage.success("操作成功");
onConfirmSuccess();
const apiCall = row.status === 1 ? disableDict(row.id) : enableDict(row.id);
// row.status === 1 ? await disableDict(row.id) : await enableDict(row.id);
await handleAction(apiCall, row.id, getTableData);
} catch (error) {
console.log("error", error);
}
};
// 刷新Table数据信息
const onConfirmSuccess = () => {
dictTableRef.value && dictTableRef.value.refresh();
const onConfirmSuccess = async () => {
if (selectItem.id) {
handleAction(
Promise.resolve(true),
selectItem.id,
getTableData,
{ showMsg: false }
);
}
else {
dictTableRef.value?.refresh();
}
};
// TODO:字段配置
// 字段配置
const handlefieldsConfig = (ite) => {
fieldsConfigRef.value.open({ ...ite, parentId: ite.id });
};
@@ -281,8 +286,8 @@ const handleDelete = async (item) => {
ElMessageBox.confirm("确定要删除吗?", "提示", { type: "warning" })
.then(async () => {
try {
await deleteDictValue(item.id);
dictTableRef.value && dictTableRef.value.refresh();
// await deleteDictValue(item.id);
await runDelete(deleteDictValue,item.id);
} catch (error) {
console.log("fetch error", error);
}
@@ -302,19 +307,5 @@ const handleDelete = async (item) => {
align-items: center;
gap: 14px;
}
.mj-status-dot {
cursor: pointer;
&::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--data-status-color);
margin-right: 8px;
vertical-align: middle;
}
}
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<el-drawer
v-model="drawerVisible"
size="100%"
class="standard-ui-back-drawer"
:close-on-click-modal="false"
:close-on-press-escape="false"
destroy-on-close
:with-header="false"
modal-class="standard-overlay-dialog-flat"
@opened="isOpened = true"
@closed="isOpened = false"
>
<!-- 头部 -->
<!-- 内容 -->
<div class="flow-context-wrapper">
<nodeFlow v-if="drawerVisible" />
</div>
<!-- -->
<template #footer>
<div class="custom-flat-drawer-footer">
<div class="stats-info"></div>
<div class="actions">
<el-button link @click="drawerVisible = false">取消</el-button>
<el-button type="primary" class="btn-confirm">确认保存变更</el-button>
</div>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import nodeFlow from "@/components/nodeFlow/index.vue";
defineOptions({ name: "FlowDetail" });
const drawerVisible = defineModel("drawerVisible", { default: true });
const isOpened = ref(false);
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,96 @@
<template>
<div class="flow-card">
<div class="card-cover">
<img src="https://via.placeholder.com/300x180" />
</div>
<div class="card-content">
<div class="title-row">
<h3 class="mj-ellipsis-one-line title">商机通用跟进流程</h3>
<el-icon class="more-icon"><MoreFilled /></el-icon>
</div>
<div class="meta-info">
<span>创建人程彬</span>
<span class="divider">|</span>
<span>2024-12-20</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { MoreFilled } from "@element-plus/icons-vue";
defineOptions({ name: "FlowCard" });
</script>
<style lang="scss" scoped>
.flow-card {
--card-active-border-color: #409eff;
--card-radius:5px;
width: 100%;
background: #ffffff;
border-radius: var(--card-radius);
overflow: hidden;
border: 1px solid #dce2e7;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
border-color: var(--card-active-border-color);
transform: translateY(-1px);
}
.card-cover {
width: 100%;
height: 160px;
background-color: #f5f7fa;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
}
&:hover .card-cover img {
transform: scale(1.05);
}
.card-content {
padding: 5px 12px;
.title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
.title {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #303133;
}
.more-icon {
color: #909399;
font-size: 14px;
&:hover {
color: var(--el-color-primary);
}
}
}
.meta-info {
display: flex;
align-items: center;
font-size: 10px;
color: #90a1b9;
.divider {
margin: 0 8px;
color: #dce2e7;
}
}
}
}
</style>

View File

@@ -1,13 +1,89 @@
<template>
<div class="">
</div>
<div class="mj-flow-container">
<stageBreadcrumbs title="SOP流程管理" styleClass="stage-breadcrumbs-list">
<template #content>
<OverflowTabs
:itemMap="{ id: 'id', label: 'name' }"
v-model="activeTab"
:items="tabList"
:height="60"
/>
</template>
<template #action>
<div class="search-auto-expand-input">
<el-input
placeholder="搜索流程名称..."
class="auto-expand-input"
:prefix-icon="'Search'"
v-model="searchVal"
@keyup.enter="fetchTableData"
></el-input>
</div>
</template>
</stageBreadcrumbs>
<div class="mj-flow-content">
<subTabs v-model:tabs="tabsIndex" :menuList="menuList" />
<div class="mj-card-container mj-flow-card-container">
<div v-for="t in 5" :key="t" class="mj-flow-card-grid">
<flowCard />
</div>
</div>
</div>
<!-- 详情 -->
<!-- <flow-detail /> -->
</div>
</template>
<script setup lang="ts">
import {reactive,ref,onMounted} from "vue"
import subTabs from "./subTabs.vue";
import flowCard from "./flowCard.vue";
import FlowDetail from './detail.vue';
defineOptions({})
defineOptions({ name: "Flow" });
const activeTab = ref<string>(1);
const searchVal = ref<string>("");
const tabsIndex = ref(1);
const menuList = ref([
{
id: 1,
name: "线索管理",
},
{
id: 2,
name: "客户管理",
icon: "Setting",
},
{
id: 3,
name: "游戏与工作室",
},
{
id: 4,
name: "商机管理",
},
]);
const tabList = ref([
{
id: 1,
name: "商机",
},
{
id: 2,
name: "合同",
},
{
id: 3,
name: "项目",
},
{
id: 4,
name: "招聘",
},
]);
const fetchTableData = () => {
console.log("fetchTableData");
};
</script>
<style lang="scss" scoped>
</style>
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div class="mj-subnav-container">
<div
v-for="(item, index) in menuList"
:key="index"
:class="['nav-item', { 'is-active': tabs === index }]"
@click="tabs = index"
>
{{ item.name }}
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "subTabs" });
const props = defineProps({
menuList: { type: Array, default: () => [] },
});
const tabs = defineModel("tabs");
</script>
<style lang="scss" scoped>
.mj-subnav-container {
$primary-color: var(--el-color-primary);
$bg-active: #EFF6FF;
$text-idle: #90A1B9;
$text-active: var(--el-color-primary);
$border-active:#DEECFE;
display: flex;
align-items: center;
gap: 7px;
padding: 12px 0;
.nav-item {
cursor: pointer;
padding: 2px 12px;
border-radius: 20px;
font-size: 11px;
color: $text-idle;
font-weight: 500;
transition: all 0.12s cubic-bezier(0.645, 0.045, 0.355, 1);
border: 1px solid transparent;
user-select: none;
&:hover {
color: $text-active;
}
&.is-active {
color: $text-active;
background-color: $bg-active;
border: 1px solid $border-active;
}
}
}
</style>

View File

@@ -32,8 +32,7 @@
</div>
</template>
<script setup>
import { Timer } from "@element-plus/icons-vue";
<script setup lang="ts">
const logData = [
{

View File

@@ -9,16 +9,15 @@
<div class="info-text">
<div class="title-row">
<span class="main-title">集团1</span>
<el-tag size="small" effect="plain" class="title-tag"
>集团</el-tag
>
<!-- TODO:这块不要展示tag -->
<!-- <el-tag size="small" effect="plain" class="title-tag">集团</el-tag> -->
</div>
<div class="sub-id">ID: 3</div>
</div>
</div>
<div class="right-actions">
<el-button plain :icon="EditPen">编辑配置</el-button>
<el-button type="danger" plain :icon="CircleClose">禁用</el-button>
<el-button plain :icon="'EditPen'">编辑配置</el-button>
<el-button :type="turnOn ? 'success':'danger'" plain :icon="turnOn ? 'CircleCheck' : 'CircleClose'" @click="onButtonCheck">{{turnOn ? '启用' : '禁用'}}</el-button>
</div>
</div>
</el-card>
@@ -39,8 +38,8 @@
>
<div class="info-label">{{ base.name }}</div>
<div class="info-value">
<el-button link type="primary" v-if="base.slotName === 'link'">{{base.value}} &gt;</el-button>
<span v-else>{{ base.value }}</span>
<el-button link type="primary" v-if="base.slotName === 'link'" @click="emit('item-click',{evt:'open'})">已开启 &gt;</el-button>
<span v-else>{{ info[base.value] }}</span>
</div>
</div>
</div>
@@ -78,21 +77,27 @@
</div>
</template>
<script setup>
import { OfficeBuilding, EditPen, CircleClose } from "@element-plus/icons-vue";
<script setup lang="ts">
const props = defineProps({
info:{
Object,
default: () => ({})
}
})
const emit = defineEmits(['item-click'])
const turnOn = ref(true);
const systemList = [
{
name: "生效时间",
value: "2024-01-01",
value: "",
},
{
name: "过期时间",
value: "永久生效",
value: "",
},
{
name: "最后更新",
value: "2025-12-29 16:06",
value: "",
},
{
name: "更新人",
@@ -104,22 +109,36 @@ const systemList = [
const baseList = [
{
name: "部门/公司名称",
value: "广州分公司",
value: "",
},
{
name: "上级单位",
value: "集团1",
value: "",
},
{
name: "同步企微",
value: "已开启",
value: "",
slotName: "link",
},
{
name: "部门负责人",
value: "赵康, 李思奇, 董峥",
value: "",
},
];
// 禁用-启用功能
const onButtonCheck = () =>{
turnOn.value = !turnOn.value;
}
// 开启微信
const onOpenWeixin = () =>{
console.log('onOpenWeixin')
}
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,232 @@
<template>
<div class="enterprise-dialog-wrapper">
<el-dialog
v-model="dialogVisible"
title="创建企业配置"
width="500px"
:show-close="true"
class="standard-ui-dialog"
>
<template #header>
<div class="dialog-header">
<div class="header-icon">
<el-icon><OfficeBuilding /></el-icon>
</div>
<div class="header-text">
<h3>创建企业配置</h3>
<span>对接企微组织架构</span>
</div>
</div>
</template>
<el-form :model="form" layout="vertical" label-position="top">
<!-- 编辑的时候展示 同步按钮 -->
<template v-if="isSyncConfig">
<el-form-item>
<div class="sync-card">
<div class="sync-card__content">
<h3 class="sync-card__title">开启企微同步</h3>
<p class="sync-card__desc">开启后将定期拉取企微通讯录数据</p>
</div>
<div class="sync-card__action">
<el-switch v-model="form.syncEnabled" size="large" />
</div>
</div>
</el-form-item>
</template>
<template v-else>
<el-form-item label="企业名称">
<el-input v-model="form.name" placeholder="如:名匠视界" />
</el-form-item>
</template>
<el-row :gutter="20">
<el-col :span="12" v-if="!isSyncConfig">
<el-form-item label="域名">
<el-input v-model="form.domain" placeholder="example.com">
<template #prefix
><el-icon><component :is="'Global'" /></el-icon
></template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="微信 CORPID">
<el-input v-model="form.corpId" placeholder="ww123...">
<template #prefix
><el-icon><CircleCheck /></el-icon
></template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="应用 ID">
<el-input v-model="form.appId" placeholder="1000001">
<template #prefix
><el-icon><Postcard /></el-icon
></template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="isSyncConfig ? 24 : 12">
<el-form-item label="应用密钥">
<el-input
v-model="form.appSecret"
type="password"
placeholder="••••••••"
show-password
>
<template #prefix
><el-icon><Key /></el-icon></template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="校验 TOKEN">
<el-input v-model="form.token" placeholder="输入校验 Token" />
</el-form-item>
<el-form-item label="加解密密钥">
<el-input v-model="form.aesKey" placeholder="输入 EncodingAESKey" />
</el-form-item>
</el-form>
<template #footer>
<div class="org-ganization-footer">
<el-button @click="dialogVisible = false" round size="large">取消</el-button>
<el-button
type="primary"
class="btn-confirm"
round
size="large"
@click="handleSubmit"
>确认创建</el-button
>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: "AddOrgan" });
const dialogVisible = defineModel("visible");
const {isSyncConfig=false} = defineProps<{
isSyncConfig: boolean;
}>();
const form = reactive({
name: "",
domain: "",
corpId: "",
appId: "",
appSecret: "",
token: "",
aesKey: "",
});
const handleSubmit = () => {};
</script>
<style lang="scss" scoped>
.enterprise-dialog-wrapper {
// 深度覆盖 Element Plus 样式
:deep(.el-dialog) {
border-radius: 20px;
.el-form-item__label {
font-weight: 600;
color: #5f6d7e;
padding-bottom: 4px;
font-size: 13px;
}
.el-input__wrapper {
background-color: #f7f9fb;
box-shadow: none;
border: 1px solid #e2e8f0;
border-radius: 8px;
height: 40px;
&.is-focus {
border-color: #2b6df8;
}
}
// 顶部标题布局
.dialog-header {
display: flex;
align-items: center;
gap: 15px;
.header-icon {
background-color: #edf2ff;
color: #2b6df8;
padding: 10px;
border-radius: 12px;
display: flex;
font-size: 24px;
}
.header-text {
h3 {
margin: 0;
font-size: 18px;
color: #1a1a1a;
}
span {
font-size: 12px;
color: #94a3b8;
}
}
}
.org-ganization-footer {
display: flex;
align-items: center;
justify-content: center;
.el-button {
width: 50%;
}
}
}
.sync-card {
display: flex;
align-items: center;
padding: 18px 20px;
background-color: #f8faff;
border-radius: 16px;
border: 1px solid #edf2f9;
flex: 1;
&__content {
display: flex;
flex-direction: column;
gap: 8px; // 标题和描述的间距
flex: 1;
line-height: 1;
}
&__title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1d2129; // 深色标题
}
&__desc {
margin: 0;
font-size: 14px;
color: #86909c; // 灰色描述
}
&__action {
margin-left: 24px;
flex-shrink: 0;
}
}
}
</style>

View File

@@ -2,12 +2,14 @@
<div class="mj-organization">
<!-- 顶部的tabs菜单 -->
<div class="organization-tabs">
<stageBreadcrumbs title="组织管理">
<stageBreadcrumbs title="组织管理" style-class="stage-breadcrumbs-list">
<template #content>
<OverflowTabs v-model="activeTab" :items="tabList" :height="60"/>
<OverflowTabs :itemMap="{id:'id',label:'name'}" v-model="activeTab" :items="tabList" :height="60" />
</template>
<template #action>
<el-button type="primary" :icon="Plus" plain>新增集团</el-button>
<el-button type="primary" :icon="'Plus'" plain @click="onAddGroup"
>新增集团</el-button
>
</template>
</stageBreadcrumbs>
</div>
@@ -18,24 +20,49 @@
<div class="mj-panel-title org-tree-head">组织架构</div>
<div class="org-tree-search">
<el-input
v-model="search"
:prefix-icon="Search"
v-model="filterText"
:prefix-icon="'Search'"
placeholder="搜索部门或公司"
/>
</div>
</div>
<div class="org-tree-list">
<el-tree
:data="data"
v-loading="treeLoading"
ref="treeRef"
:data="treeData"
lazy
:load="loadNode"
:filter-node-method="filterNode"
:props="defaultProps"
@node-click="handleNodeClick"
/>
>
<template #default="{ node, data }">
<div class="org-tree-item">
<div class="org-tree-item-left">
<DynamicSvgIcon
:name="getIconComponent(node)"
:size="15"
:hover-color="'#67c23a'"
:color="getIconColor(node)"
/>
<AutoTooltip :content="node.label" class="mj-ellipsis-one-line tree-node-label" />
</div>
<div class="org-tree-item-right">
<el-icon :size="15" @click.stop="onOrgTreeDelete(node,data)">
<Delete />
</el-icon>
</div>
</div>
</template>
</el-tree>
</div>
<div class="org-bottom-add">
<el-input v-model="addValue" placeholder="快速添加分公司...">
<template #suffix>
<el-button text type="primary" :icon="Plus"> </el-button>
<el-button text type="primary" :icon="'Plus'"> </el-button>
</template>
</el-input>
</div>
@@ -43,120 +70,202 @@
<div class="mj-organization-card organization-info">
<el-tabs v-model="activeName" class="organization-info-tabs">
<el-tab-pane label="基础信息" name="baseInfo">
<OrganizationDetail />
<OrganizationDetail :info="detailInfo" @item-click="handleItemClick"/>
</el-tab-pane>
<el-tab-pane label="动态日志" name="auditLogs">
<AuditLogs />
<AuditLogs :info="detailInfo"/>
</el-tab-pane>
</el-tabs>
</div>
</div>
<!-- 添加集团-->
<addOrgan v-model:visible="showAddOrgan" :isSyncConfig="isSyncConfig"/>
</div>
</template>
<script setup lang="ts">
import { Plus, Search } from "@element-plus/icons-vue";
import stageBreadcrumbs from "@/components/stageBreadcrumbs/index.vue";
import OverflowTabs from "@/components/overflowTabs/index.vue";
import AuditLogs from "./AuditLogs.vue";
import OrganizationDetail from "./OrganizationDetail.vue";
import { OfficeBuilding, EditPen, CircleClose } from "@element-plus/icons-vue";
defineOptions({ name: "Organization" });
const addValue = ref("");
const search = ref("");
const activeName = ref("baseInfo");
// 集团Tabs切换
const activeTab = ref(1);
const tabList = ref([
{ id: 1, label: '集团1' },
{ id: 2, label: '集团2' },
{ id: 3, label: '集团3' },
{ id: 4, label: '集团4' },
{ id: 5, label: '集团5' },
{ id: 6, label: '集团6' },
]);
import AuditLogs from "./auditLogs.vue";
import OrganizationDetail from "./organizationDetail.vue";
import DynamicSvgIcon from "@/components/dynamicSvgIcon/index.vue";
import addOrgan from "./addOrgan.vue";
import AutoTooltip from "@/components/autoTooltip/index.vue";
import { debounce } from 'lodash-es'
import {
getEnterprise,
addEnterprise,
enableEnterprise,
disableEnterprise,
getEnterpriseOrg,
getEnterpriseUser,
getEnterpriseDetail,
getEnterpriseOrgDetail
} from "@/api/stage/organization";
interface Tree {
label: string;
children?: Tree[];
}
defineOptions({ name: "Organization" });
const handleNodeClick = (data: Tree) => {
console.log(data);
};
const data: Tree[] = [
{
label: "Level one 1",
children: [
{
label: "Level two 1-1",
children: [
{
label: "Level three 1-1-1",
},
],
},
],
},
{
label: "Level one 2",
children: [
{
label: "Level two 2-1",
children: [
{
label: "Level three 2-1-1",
},
],
},
{
label: "Level two 2-2",
children: [
{
label: "Level three 2-2-1",
},
],
},
],
},
{
label: "Level one 3",
children: [
{
label: "Level two 3-1",
children: [
{
label: "Level three 3-1-1",
},
],
},
{
label: "Level two 3-2",
children: [
{
label: "Level three 3-2-1",
},
],
},
],
},
];
const addValue = ref("");
const activeName = ref("baseInfo");
const isSyncConfig = ref(false);
const showAddOrgan = ref(false);
const treeData = ref([]);
// 集团Tabs切换
const activeTab = ref('');
const tabList = ref([]);
const filterText = ref('');
const treeRef = ref(null);
const treeLoading = ref(false);
const detailInfo = reactive<Record<string, any>>({});
const defaultProps = {
children: "children",
label: "label",
label: "name",
isLeaf:'leaf'
};
// children相关事件
const handleItemClick = (item)=>{
const { evt } = item;
if(evt === 'open'){
showAddOrgan.value = true;
isSyncConfig.value = true;
}
}
// 加载子机构
const loadNode = async (node, resolve, reject) =>{
try {
const response = await getEnterpriseOrg(node.id);
resolve(response);
} catch (error) {
console.log('search error:',error);
}
}
watch(filterText, (val) => {
treeRef.value!.filter(val)
})
// 过滤节点
const filterNode = (value: string, data:Record<string, any>) => {
if (!value) return true
return data.name.includes(value)
}
// 添加集团
const onAddGroup = () => {
showAddOrgan.value = true;
};
// 获取图标组件
const getIconComponent = (node: any) => {
if (node.level === 1) {
return "building"; // 一级节点使用
} else if (node.level === 2) {
return `flag`; // 二级节点使用其他图标
} else {
return "flag"; // 更深层级的默认图标
}
};
// 获取图标颜色(可选)
const getIconColor = (node: any) => {
return "#9EADC2";
};
// 获取企业列表
const getEnterpriseList = async () => {
try {
const queryParams = { name: "", domain: "" };
const res = await getEnterprise(queryParams);
activeTab.value = res[0]?.id || "";
getOrgTree(activeTab.value);
tabList.value = res;
} catch (error) {
console.log('getEnterpriseList error:',error);
}
};
// 获取组织架构数
const getOrgTree = async (id:number|string) => {
treeLoading.value = true;
try {
const response = await getEnterpriseOrg(id);
treeData.value = response;
} catch (error) {
console.log('get org error:',error);
treeData.value = [];
} finally {
treeLoading.value = false;
}
};
// 获取机构-企业详情数据
const apiMap = {
1: getEnterpriseOrg, //机构详情
2: getEnterpriseUser //员工详情
}
const orgDetail = async (data:Record<string,any>) =>{
const { kind,id } = data
try {
const response = await apiMap[kind](id);
Object.assign(detailInfo, response);
} catch (error) {
console.log('orgDetail error:',error);
}
}
// 获取企业详情
const getEnterpriseOrgDetail = async () => {
try {
const response = await getEnterpriseDetail();
console.log('获取当前的企业详情数据信息:',response);
} catch (error) {
console.log('getEnterpriseDetail error:',error);
}
}
// 删除部门-人员-组织
const onOrgTreeDelete = async (node,data) => {
ElMessageBox.confirm("确定要删除吗?", "提示", { type: "warning" })
.then(async () => {
try {
// await disableEnterprise(data.id);
treeRef.value && treeRef.value.remove(node);
} catch (error) {
console.log('fetch error',error);
}
})
}
// 请求机构
const debouncedGetOrgTree = debounce((id) => {
if(!id) return;
getOrgTree(id);
}, 300);
// 监听切换
watch(activeTab, (val) => {
debouncedGetOrgTree(val);
});
// 点击树节点获取数据
const handleNodeClick = (data: Tree) => {
orgDetail(data);
};
onMounted(()=>{
Promise.all([getEnterpriseOrgDetail(),getEnterpriseList()]);
})
</script>
<style lang="scss" scoped>
@use "sass:math";
.mj-organization {
.organization-tabs{
:deep(.stage-breadcrumbs){
// border-bottom: none;
padding:0;
}
}
height: 100%;
.mj-organization-card {
border-radius: 16px;
border: 1px solid #e2e8f099;
@@ -165,6 +274,7 @@ const defaultProps = {
box-shadow: 0 0 6px #e9e8e8;
}
.organization-content {
height: calc(100% - 60px);
display: flex;
gap: 16px;
.org-tree {
@@ -184,6 +294,36 @@ const defaultProps = {
flex: 1;
overflow: auto;
padding: math.div($mj-padding-standard, 2) $mj-padding-standard;
.org-tree-item {
display: flex;
justify-content: space-between;
align-items: center;
min-width: 0;
width: 100%;
.org-tree-item-right {
color: #a5adb8;
&:hover {
color: #2065fc;
}
}
:deep(.el-icon) {
vertical-align: middle;
}
.org-tree-item-left {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
:deep(.el-icon) {
margin-right: 3px;
}
.tree-node-label{
display: inline-block;
width: 100%;
}
}
}
}
.org-bottom-add {
border-top: 1px solid #f1f5f9;

View File

@@ -0,0 +1,306 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="computedTitle"
width="500px"
class="standard-ui-flat-dialog"
modal-class="standard-overlay-dialog-flat"
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="false"
top="6vh"
>
<el-form ref="formRef" :model="form" label-position="top" :rules="rules">
<div class="row-flex">
<el-form-item label="角色名称" prop="name">
<el-input v-model="form.name" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="角色编码" prop="code">
<el-input
v-model="form.code"
placeholder="ROLE_CODE"
:disabled="detailId ? true : false"
/>
</el-form-item>
</div>
<el-form-item label="角色类型" prop="type">
<div class="full-width-radio">
<BaseSegmented
v-model="form.type"
:options="dicts.permission_role_type"
/>
</div>
</el-form-item>
<el-form-item prop="isOrgRelated">
<div class="feature-card light-green">
<div class="info">
<div class="title">组织架构</div>
<div class="desc">是否将该角色与系统组织架构体系进行深度关联</div>
</div>
<el-switch
v-model="form.isOrgRelated"
style="
--el-switch-on-color: #13ce66;
--el-switch-off-color: #cad5e2;
"
:active-value="1"
:inactive-value="0"
/>
</div>
</el-form-item>
<el-form-item label="关联岗位" prop="positionIds">
<el-select
v-model="form.positionIds"
placeholder="请选择关联岗位"
multiple
filterable
remote
:remote-method="remoteMethod"
:loading="remoteLoading"
collapse-tags
:fit-input-width="true"
:teleported="false"
v-select-more="handleLoadMore"
>
<el-option
:label="item.name"
:value="item.id"
v-for="(item, index) in options"
:key="item.id"
/>
<el-option v-if="loadMore" disabled value="more" class="mj-select-dropdown-loading">
<div class="status-container">
<el-icon v-if="loadMore" class="is-loading" :size="20"
><Loading
/></el-icon>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item prop="status">
<div class="feature-card light-blue">
<div class="info">
<div class="title">启用状态</div>
<div class="desc">控制该角色及其下属权限是否立即生效</div>
</div>
<el-switch
v-model="form.status"
:active-value="1"
:inactive-value="0"
/>
</div>
</el-form-item>
<el-form-item label="备注说明" prop="remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
resize="none"
placeholder="请输入备注说明..."
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelRoles" text>取消</el-button>
<el-button type="primary" @click="handleSubmit(formRef)"
>确认创建</el-button
>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import BaseSegmented from "./baseSegmented.vue";
import { Loading } from "@element-plus/icons-vue";
import type { FormInstance, FormRules } from "element-plus";
import {
addRole,
updateRole,
getRoleDetail,
} from "@/api/stage/permission/index.ts";
import { getEnterprisePosition } from "@/api/stage/organization";
import { useSelectLoadMore } from "@/hooks/useSelectLoadMore";
import { useDict } from '@/hooks/useDictData';
const { dicts,refresh } = useDict('permission_role_type');
defineOptions({ name: "addRoles" });
const dialogVisible = defineModel("visible", { type: Boolean, default: false });
const props = defineProps({
detailId: {
type: [String, Number],
default: "",
},
});
const {
options,
remoteLoading,
loadMore,
noMore,
remoteMethod,
handleLoadMore,
} = useSelectLoadMore({
fetchApi: (p) => getEnterprisePosition({ ...p }),
});
const computedTitle = computed(() => {
return props.detailId ? "编辑系统角色" : "新增系统角色";
});
const emit = defineEmits(["on-success"]);
const positionList = ref([]); //岗位列表数据
const formRef = ref<FormInstance>(null);
const form = reactive<RuleForm>({
name: "",
code: "",
type: "DEFAULT",
isOrgRelated: 0, // 是否和组织架构深度关联
positionIds: "",
status: 1,
remark: "",
});
const rules = reactive<FormInstance<RuleForm>>({
name: [{ required: true, message: "请输入角色名称", trigger: "blur" }],
code: [{ required: true, message: "请输入角色编码", trigger: "blur" }],
});
// 分页信息
const pageInfo = reactive({
pageNo: 1,
pageSize: 20,
});
const cancelRoles = () => {
formRef.value && formRef.value.resetFields();
dialogVisible.value = false;
};
// 获取角色详情
const getRoleShowDetail = async (val) => {
try {
const res = await getRoleDetail(props.detailId);
Object.assign(form, res, { isOrgRelated: res.isOrgRelated ? 1 : 0 });
} catch (error) {
console.log("getRoleDetail error:", error);
}
};
// 监听详情
watch(
() => dialogVisible.value,
(val) => {
if (val && props.detailId) {
getRoleShowDetail();
}
}
);
// 新增编辑角色(仅系统类型可新增)
const handleSubmit = (formEl: FormInstance | undefined) => {
if (!formEl) return;
formEl.validate(async (valid) => {
if (valid) {
try {
const res = props.detailId
? await updateRole(form)
: await addRole(form);
ElMessage.success("操作成功");
cancelRoles();
emit("on-success");
} catch (error) {
console.log(error);
}
} else {
console.log("error submit!");
}
});
};
// 初始化刷新角色类型值
onMounted(() => {
refresh();
});
</script>
<style lang="scss" scoped>
.standard-ui-flat-dialog {
.el-dialog__header {
.el-dialog__title {
position: relative;
padding-left: 10px;
font-size: 14px;
&::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 16px;
background-color: var(--blue-block-color);
border-radius: 2px;
}
}
}
.el-dialog__body {
padding: 32px;
}
// 表单标签样式
.el-form-item__label {
font-weight: 500;
color: #86909c;
margin-bottom: 8px;
}
// 两列布局
.row-flex {
display: flex;
gap: 16px;
.el-form-item {
flex: 1;
}
}
.full-width-radio {
width: 100%;
}
// 绿色和蓝色的特色功能卡片
.feature-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-radius: 4px;
flex: 1;
.info {
.title {
font-size: 14px;
font-weight: 600;
color: #1d2129;
margin-bottom: 4px;
}
.desc {
font-size: 12px;
color: #86909c;
}
}
&.light-green {
background-color: #f6fffa; // 浅绿色背景
border: 1px solid #e8f3ee;
}
&.light-blue {
background-color: #f0f7ff; // 浅蓝色背景
border: 1px solid #e8f0f9;
}
}
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<div class="permission-scroll-area">
<div v-for="group in menuList" :key="group.id" class="permission-group">
<div class="group-header">
<el-checkbox
v-model="group.selected"
:indeterminate="group.isIndeterminate"
@change="(val) => handleGroupCheckAll(group, val)"
>
<span class="group-title">{{ group.name }}</span>
<span class="group-count" v-if="group.children">({{ getCheckedCount(group) }}/{{ group.children.length }})</span>
</el-checkbox>
</div>
<div class="group-body">
<div v-for="row in group.children" :key="row.id" class="permission-row">
<div class="row-label">
<el-checkbox
v-model="row.selected"
:indeterminate="row.isIndeterminate"
@change="() => handleRowChange(group, row)"
>
{{ row.name }}
</el-checkbox>
</div>
<div class="row-actions">
<el-checkbox-group
v-model="row.actions"
@change="() => handleActionChange(group, row)"
>
<el-checkbox :value="check.id" v-for="(check,checkIndex) in row.operations" :class="check.code.search('delete') > -1 ? 'is-danger' : ''">{{ check.name }}</el-checkbox>
</el-checkbox-group>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({name: "baseSegmentedMenu"});
const props = defineProps({
menuList: {
type: Array,
default: () => [],
},
});
// 获取已选中的子项数量
const getCheckedCount = (group) =>{
return (group.children || []).filter((item) => item.selected).length;
}
// 处理一级全选
const handleGroupCheckAll = (group, val) => {
group.children.forEach((row) => {
row.selected = val;
row.actions = val ? row.operations.map((item) => item.id) : [];
});
group.isIndeterminate = false;
};
// 处理二级勾选
const handleRowChange = (group, row) => {
row.actions = row.selected ? row.operations.map((item) => item.id) : [];
updateGroupStatus(group);
};
// 处理三级按钮勾选
const handleActionChange = (group, row) => {
if (row.actions.length > 0) row.selected = true;
const allActions = row.operations && row.operations.length;
const checkedActionsCount = row.actions ? row.actions.length : 0;
row.isIndeterminate = checkedActionsCount > 0 && checkedActionsCount < allActions;
row.selected = checkedActionsCount > 0 && checkedActionsCount === allActions;
updateGroupStatus(group);
};
// 更新父级的半选/全选状态
const updateGroupStatus = (group) => {
const children = group.children || [];
const total = children.length;
const fullyCheckedCount = children.filter(c => c.selected && !c.isIndeterminate).length;
const anyCheckedCount = children.filter(c => c.selected || c.isIndeterminate).length;
group.selected = total > 0 && fullyCheckedCount === total;
group.isIndeterminate = !group.selected && anyCheckedCount > 0;
};
</script>
<style lang="scss" scoped>
@use './baseSegmentedPermission.scss' as *;
</style>

View File

@@ -0,0 +1,106 @@
<template>
<div class="base-segmented-flat">
<div v-for="item in options" :key="item.value" class="segmented-item">
<input
type="radio"
:id="uid + item.value"
:name="uid"
:value="item.value"
:checked="modelValue === item.value"
@change="handleChange(item.value)"
/>
<label
:for="uid + item.value"
:class="{ 'is-active': modelValue === item.value }"
>
{{ item.label }}
</label>
</div>
</div>
</template>
<script setup>
import { useFormItem } from "element-plus";
const props = defineProps({
modelValue: [String, Number],
options: {
type: Array,
default: () => [],
},
});
watch(() => props.options, (val) => {
// 1. 获取父组件的值
console.log("父组件的值111", val);
},{deep:true});
const emit = defineEmits(["update:modelValue", "change"]);
// 生成唯一ID防止页面存在多个组件时 name 冲突
const uid = `seg-${Math.random().toString(36).substring(2, 8)}`;
const { formItem } = useFormItem();
const handleChange = (val) => {
// 2. 更新父组件的值
emit("update:modelValue", val);
emit("change", val);
// 通知 el-form-item 进行校验
if (formItem) {
formItem.validate("change").catch(() => {});
}
};
</script>
<style lang="scss" scoped>
@use "sass:color";
$bg-color: #f2f3f5;
$active-bg: #ffffff;
$primary-color: #1661ff;
$text-secondary: #86909c;
.base-segmented-flat {
display: flex;
align-items: center;
width: 100%;
background-color: $bg-color;
padding: 4px;
border-radius: 4px;
box-sizing: border-box;
.segmented-item {
flex: 1;
display: flex;
input[type="radio"] {
display: none;
}
label {
flex: 1;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: $text-secondary;
cursor: pointer;
transition: all 0.2s ease-in-out;
border-radius: 4px;
user-select: none;
&:hover {
color: color.adjust($text-secondary, $lightness: 15%);
}
// 选中状态样式
&.is-active {
background-color: $active-bg;
color: $primary-color;
font-weight: 500;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
}
}
}
</style>

View File

@@ -0,0 +1,106 @@
.permission-container {
--primary-blue: #1661ff;
--danger-red: #f53f3f;
--text-main: #1d2129;
--text-grey: #86909c;
--border-color: #f2f3f5;
--common-padding:24px;
.custom-permission-tabs {
--el-tabs-header-height:50px;
--el-border-color-light:transparent;
:deep(.el-tabs__header){
padding: 0 var(--common-padding);
background-color: #FBFCFD;
margin-bottom: 0;
}
:deep(.el-tabs__content){
border-top: 1px solid var(--border-color);
}
:deep(.el-tabs__item) {
font-size: 14px;
&.is-active {
color: var(--primary-blue);
}
}
}
.permission-scroll-area{
padding: 0 var(--common-padding);
box-sizing: border-box;
height: calc(100vh - 194px); //设置固定的高度
overflow-y: auto;
}
.permission-group {
padding: 20px 0;
border-bottom: 1px solid var(--border-color);
.group-header {
margin-bottom: 16px;
.group-title {
font-weight: 600;
color: var(--text-main);
font-size: 15px;
}
.group-count {
color: var(--text-grey);
margin-left: 8px;
font-size: 12px;
}
/* 图二中:半选状态的蓝色方块样式 */
:deep(.el-checkbox__input.is-indeterminate .el-checkbox__inner) {
background-color: var(--primary-blue);
border-color: var(--primary-blue);
&::before {
height: 3px;
top: 6px;
}
}
}
.group-body {
padding-left: 28px;
.permission-row {
display: flex;
align-items: flex-start;
margin-bottom: 12px;
.row-label {
width: 180px;
:deep(.el-checkbox__label) {
color: var(--text-grey);
}
}
.row-actions {
flex: 1;
display: flex;
justify-content: flex-end;
:deep(.el-checkbox) {
margin-right: 32px;
/* 默认选中文字也保持蓝色 */
&.is-checked .el-checkbox__label {
color: var(--primary-blue);
}
/* 特殊处理:删除 按钮 */
&.is-danger.is-checked .el-checkbox__label {
color: var(--danger-red);
}
}
}
}
}
}
/* 禁用时的文字颜色微调 */
:deep(.el-checkbox.is-disabled .el-checkbox__label) {
color: #c0c4cc;
}
}

View File

@@ -0,0 +1,363 @@
<template>
<div class="field-permission-manager">
<div class="module-grid">
<div
v-for="mod in modules"
:key="mod.id"
class="module-card"
:class="{ 'is-active': activeModules === mod.id }"
@click="handleModuleChange(mod.id)"
>
{{ mod.name }}
</div>
</div>
<div class="field-group-list">
<template v-if="fieldGroupsChildren.length">
<div
v-for="(group, index) in fieldGroupsChildren"
:key="group.id"
class="group-container"
:class="{ 'is-collapsed': collapsedIds.has(group.id) }"
>
<div class="group-header" :class="{ 'is-collapsed': collapsedIds.has(group.id) }">
<div class="header-left">
<el-icon><List /></el-icon>
<span class="group-title">{{ group.name }}</span>
</div>
<div class="header-right">
<span class="quick-set-label">快速设置</span>
<div class="quick-actions">
<span
v-for="opt in dicts.permission_setting_status"
:key="opt.value"
class="quick-btn"
:class="{ 'is-disabled': isProcessing }"
@click.stop="batchSetPermissionChunked(group, opt.value)"
>
{{ opt.label }}
</span>
</div>
<el-icon class="collapse-icon" @click.stop="toggleGroup(group)"
><ArrowDown
/></el-icon>
</div>
</div>
<div class="field-rows-collapse">
<div class="field-rows-inner">
<div
v-for="field in group.fields"
:key="field.id"
class="field-item"
>
<div class="field-info">
<i class="tree-line-icon"></i>
<span class="field-label">{{ field.name }}</span>
</div>
<div class="field-ctrl">
<el-select v-model="field.permission">
<el-option
:label="dict.label"
:value="dict.value"
v-for="(
dict, dicIndex
) in dicts.permission_setting_status"
:key="dict.value"
/>
</el-select>
</div>
</div>
</div>
</div>
</div>
</template>
<el-empty description="暂无数据~" v-else/>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { List, Document, ArrowDown } from "@element-plus/icons-vue";
const props = defineProps({
dicts:{
type:Object,
default:()=>({})
},
fieldGroupsList: {
type: Array,
default: () => [],
}
});
const emit = defineEmits(["update:fieldGroupsList"]);
const isProcessing = ref(false);
const processingGroupId = ref(null);
const currentActiveId = ref(null);
const collapsedIds = ref(new Set()); //可折叠的集合
// 渲染顶部的数据
const modules = computed(() => {
return props.fieldGroupsList.map((group) => {
return {
id: group.id,
name: group.name,
};
});
});
// 动态渲染activeModules的值
const activeModules = computed(() => currentActiveId.value);
// 动态匹配对应的下级数据
const fieldGroupsChildren = computed({
get: () => {
if (!currentActiveId.value) return [];
return (
props.fieldGroupsList.find((group) => group.id === currentActiveId.value)
?.fieldGroups || []
);
},
set: () => {},
});
// 监听值的变化
watch(
() => props.fieldGroupsList,
(newList) => {
if (newList.length > 0) {
const exists = newList.find((item) => item.id === currentActiveId.value);
if (!exists) {
currentActiveId.value = newList[0].id;
}
} else {
currentActiveId.value = null;
}
},
{ immediate: true, deep: true }
);
const batchSetPermissionChunked = (group, type) => {
if (isProcessing.value) return;
const fields = group.fields;
const total = fields.length;
const chunkSize = 40;
let currentIndex = 0;
isProcessing.value = true;
processingGroupId.value = group.groupId;
// 同步更新permission的数据
group.permission = type;
const runChunk = () => {
const nextLimit = Math.min(currentIndex + chunkSize, total);
for (let i = currentIndex; i < nextLimit; i++) {
fields[i].permission = type;
}
currentIndex = nextLimit;
if (currentIndex < total) {
// 关键:将控制权交还给浏览器渲染线程,下一帧继续
requestAnimationFrame(runChunk);
} else {
isProcessing.value = false;
processingGroupId.value = null;
emit("update:fieldGroupsList", [...props.fieldGroupsList]);
}
};
runChunk();
};
// 切换折叠面板
const toggleGroup = (group) => {
if (collapsedIds.value.has(group.id)) {
collapsedIds.value.delete(group.id);
} else {
collapsedIds.value.add(group.id);
}
};
// 切换顶部权限的模块
const handleModuleChange = (id) => {
currentActiveId.value = id;
};
</script>
<style lang="scss" scoped>
.field-permission-manager {
--primary-blue: #1661ff;
--bg-light: #f2f3f5;
--border-color: #e5e6eb;
--transition-speed: 0.3s;
overflow-y: auto;
height: calc(100vh - 190px);
box-sizing: border-box;
.module-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 24px;
padding: 24px 24px 0;
user-select: none;
.module-card {
width: calc((100% - 48px) / 5);
min-width: 100px;
height: 40px;
border: 1px solid var(--border-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
color: #4e5969;
box-sizing: border-box;
transition: all 0.2s;
&:hover {
border-color: var(--primary-blue);
color: var(--primary-blue);
}
&.is-active {
background: var(--primary-blue);
border-color: var(--primary-blue);
color: #fff;
font-weight: 500;
}
}
}
.field-group-list {
padding: 0 24px;
}
// 2. 字段卡片样式
.group-container {
border: 1px solid var(--bg-light);
border-radius: 6px;
margin-bottom: 16px;
overflow: hidden;
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 16px;
height: 48px;
background: #fff;
border-bottom: 1px solid var(--bg-light);
.header-left {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
.el-icon {
color: #86909c;
font-size: 18px;
}
}
.header-right {
display: flex;
align-items: center;
.quick-set-label {
font-size: 12px;
color: #86909c;
margin-right: 8px;
}
.quick-actions {
display: flex;
gap: 8px;
margin-right: 16px;
.quick-btn {
padding: 2px 10px;
background: #f7f8fa;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 12px;
color: var(--primary-blue);
cursor: pointer;
&:hover {
background: #eff4ff;
}
}
}
.collapse-icon {
color: #c9cdd4;
cursor: pointer;
}
}
}
.field-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px 10px 32px;
border-bottom: 1px solid #fafafa;
&:last-child {
border-bottom: none;
}
.field-info {
display: flex;
align-items: center;
.tree-line-icon {
width: 12px;
height: 12px;
border-left: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
margin-right: 8px;
margin-top: -6px;
}
.field-label {
font-size: 14px;
color: #4e5969;
}
}
.field-ctrl {
flex: 0 0 120px;
}
}
.field-rows-collapse {
max-height: 6000px;
overflow: hidden;
transition: max-height var(--transition-speed)
cubic-bezier(0.4, 0, 0.2, 1),
opacity var(--transition-speed);
opacity: 1;
}
// 折叠后的状态
&.is-collapsed {
.group-header {
border-bottom-color: transparent;
}
.field-rows-collapse {
max-height: 0;
opacity: 0;
transition: max-height var(--transition-speed) cubic-bezier(0, 1, 0, 1),
opacity var(--transition-speed);
}
.collapse-icon {
transform: rotate(-90deg);
}
}
.collapse-icon {
transition: transform var(--transition-speed);
}
}
}
</style>

View File

@@ -1,12 +1,366 @@
<template>
<div class="mj-permission-management">
</div>
<div class="mj-permission-management">
<stageBreadcrumbs title="权限管理" style-class="stage-breadcrumbs-list">
<template #content>
<OverflowTabs v-model="activeTab" :items="tabList" :height="60" />
</template>
<template #action>
<div class="mj-permission-actions">
<div class="search-auto-expand-input">
<el-input
:placeholder="checkRolesText.placeholder"
class="auto-expand-input"
:prefix-icon="'Search'"
v-model="searchVal"
@keyup.enter="onRefresh"
></el-input>
</div>
<div class="mj-dict-actions-right">
<el-button
:icon="'Plus'"
type="primary"
plain
@click="addBtnClick"
>{{ checkRolesText.btnText }}</el-button
>
</div>
</div>
</template>
</stageBreadcrumbs>
<!-- 表格 -->
<CommonTable
ref="tableRef"
:columns="tableColumns"
:request-api="getTableData"
>
</CommonTable>
</div>
<!-- 成员管理 -->
<member-selector
v-model:visible="showMember"
v-model:dataList="memberList"
@save-member="handleSaveMember"
/>
<!-- 新增角色 -->
<add-roles
v-model:visible="showRoles"
@on-success="onRefresh"
:detail-id="selectMember?.id"
/>
<!-- 权限抽屉 -->
<permission-drawer
v-model:visible="showPermission"
:checkAuth="selectMember"
/>
</template>
<script setup lang="ts">
import dayjs from "dayjs";
import CommonTable from "@/components/proTable/proTablev2.vue";
import memberSelector from "@/components/memberSelector/index.vue";
import addRoles from "./addRoles.vue";
import permissionDrawer from "./permissionDrawer.vue";
import { DictManage } from "@/dict";
import { useDict } from "@/hooks/useDictData";
import { formatIndex } from "@/utils/utils";
import {
getRoleList,
getRoleDetail,
updateRole,
addRole,
deleteRole,
enableRole,
disableRole,
getRoleMemberList,
copyRolePermission,
batchSaveRole,
} from "@/api/stage/permission/index.ts";
import { useLocalManager } from "@/hooks/useLocalManager";
import { useTableAction } from "@/hooks/useTableAction";
defineOptions({ name: "PermissionManagement" });
const { dicts, refresh } = useDict("permission_list_enable_disable");
const activeTab = ref(1);
const tableRef = ref(null);
const { handleAction, handleDelete } = useTableAction(tableRef);
const searchVal = ref("");
const memberList = ref([]); // 获取成员角色列表
const total = ref(0);
const showMember = ref(false);
const showRoles = ref(false);
const showPermission = ref(false);
const selectMember = ref({}); //当前选择的成员数量数据
const tabList = [
{
label: "角色与权限",
id: 1,
},
];
defineOptions({ name: "PermissionManagement"})
const { fullData, displayData, loadMore, noMore, remove, add } =
useLocalManager(() => memberList.value);
const roleColumns = [
{
prop: "id",
label: "编号",
width: "80",
align: "center",
render: ({ rowData, rowIndex }) => {
return h("span", `#${formatIndex(rowIndex)}`);
},
},
{
prop: "name",
label: "角色名称",
align: "center",
valueType: "ellipsis",
width: 150,
},
{
prop: "memberCount",
label: "成员数量",
align: "center",
render: ({ rowData }: any) => {
return h(
ElButton,
{
type: "primary",
link: true,
icon: "UserFilled",
onClick: (e) => {
e.stopPropagation();
addMember(rowData);
},
},
() => rowData.memberCount
);
},
},
{
prop: "permissionCount",
label: "权限数量",
align: "center",
render:({rowData})=>{
return h('span',rowData.permissionCount ? `${rowData.permissionCount}` : "-")
}
},
{
prop: "status",
label: "状态",
align: "center",
valueType: "status",
options: computed(() => dicts.value.permission_list_enable_disable),
onClick: ({ cellValue, rowData }) => {
handleDictStatus(rowData);
},
},
{
prop: "type",
label: "角色类型",
align: "center",
render: ({ rowData }: any) => {
return h(ElTag, { size: "small" }, () => rowData.type);
},
},
{
prop: "positionIds",
label: "关联岗位",
align: "center",
valueType: "ellipsis",
render: ({ rowData, rowIndex }) => {
return h("span", rowData.positionIds ? rowData.positionIds.join(",") : "-");
},
},
{
prop: "updateTime",
label: "更新时间",
align: "center",
valueType: "date",
format: "YYYY-MM-DD HH:mm",
},
{
prop: "updater",
label: "更新人",
align: "center",
},
{
prop: "actions",
label: "操作",
align: "right",
width: 200,
actions: [
{
label: "权限",
type: "primary",
link: true,
permission: ["*"],
onClick: (row) => {
showPermission.value = true;
selectMember.value = { ...row };
},
},
{
label: "复制",
type: "default",
link: true,
permission: ["*"],
onClick: async (row) => {
try {
await copyRolePermission(row.id);
ElMessage.success("复制成功");
tableRef.value.refresh();
} catch (error) {
console.log("copy error", error);
}
},
},
{
label: "编辑",
type: "default",
link: true,
permission: ["*"],
onClick: (row) => {
showRoles.value = true;
selectMember.value = { ...row };
},
},
{
label: "删除",
type: "danger",
link: true,
permission: ["*"],
disabled: (row) => {
return row.type !== "SYSTEM";
},
onClick: async (row) => {
ElMessageBox.confirm("确定要删除吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(async () => {
try {
await handleDelete(deleteRole,row.id)
} catch (error) {
console.log("error", error);
}
})
.catch(() => {
console.log("cancel");
});
},
},
],
},
];
const tableColumns = computed(() => {
const columns = {
1: roleColumns,
}[activeTab.value];
return columns;
});
// 当前成员数量
const addMember = (row) => {
showMember.value = true;
selectMember.value = { ...row };
getMemberList({ id: row.id });
};
// 保存成员数量
const handleSaveMember = async (row) => {
const { id } = selectMember.value;
const userIds = memberList.value.map((item) => item.id);
try {
await handleAction(()=>batchSaveRole(id,{userIds}),id, getTableData);
} catch (error) {
console.log("batch save error", error);
}
};
// 刷新列表
const onRefresh = () => {
tableRef.value && tableRef.value.refresh();
};
// 监听Tabs变化(目前暂时先用不到)
// watch(activeTab, () => {
// dataList.value = [];
// tableRef.value && tableRef.value.refresh();
// });
// 启用-停用
const handleDictStatus = async (row) => {
try {
// row.status === 1 ? await disableRole(row.id) : await enableRole(row.id);
const apiCall = row.status === 1 ? disableRole(row.id) : enableRole(row.id);
await handleAction(apiCall, row.id, getTableData);
} catch (error) {
console.log("error", error);
}
};
// 请求接口获取
const getTableData = async (params) => {
// 正式请求
const queryParams = {
...(searchVal.value && { name: searchVal.value }),
...params
};
try {
const res = await getRoleList(queryParams);
return res;
} catch (error) {
console.log("error", error);
}
};
// 后面会修改成全部获取获取成员角色列表
const getMemberList = async (params) => {
try {
const response = await getRoleMemberList(params.id);
memberList.value = response;
} catch (error) {
console.log("error", error);
}
};
// 获取
const checkRolesText = computed(() => {
const btnText = {
1: "新增角色",
}[activeTab.value];
const placeholder = {
1: "搜索角色名称...",
}[activeTab.value];
return {
btnText,
placeholder,
};
});
// 新增角色
const addBtnClick = () => {
if (activeTab.value === 1) {
showRoles.value = true;
selectMember.value = { id: "" };
}
};
</script>
<style lang="scss" scoped>
</style>
.mj-permission-management {
:deep(.stage-breadcrumbs) {
margin-bottom: 16px;
}
.mj-permission-actions {
display: flex;
align-items: center;
gap: 10px;
}
}
</style>

View File

@@ -0,0 +1,219 @@
<template>
<el-drawer
v-model="drawerVisible"
size="40%"
title="测试评论"
:close-on-click-modal="false"
:close-on-press-escape="false"
destroy-on-close
:with-header="false"
modal-class="standard-overlay-dialog-flat"
class="standard-ui-back-drawer"
>
<div class="permission-drawer-container">
<!-- header -->
<div class="customer-drawer-header">
<div class="title-row">
<span class="title">权限配置</span>
<el-icon class="close-icon" @click="drawerVisible = false"
><Close
/></el-icon>
</div>
<div class="sub-title">
正在为<span class="sub-roles">[{{ personAuth }}]&nbsp;</span>分配系统访问权限
</div>
</div>
<!-- 内容 -->
<div class="permission-drawer-content">
<div class="permission-container">
<el-tabs v-model="activeTab" class="custom-permission-tabs">
<el-tab-pane label="菜单权限" name="menu">
<base-segment-menu :menuList="menuList"></base-segment-menu>
</el-tab-pane>
<el-tab-pane label="字段权限" name="field">
<fieldPermissionManager
:dicts="dicts"
v-model:field-groups-list="fieldsList"
/>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
<!-- 底部footer -->
<template #footer>
<div class="custom-flat-drawer-footer">
<div class="stats-info"></div>
<div class="actions">
<el-button link @click="drawerVisible = false">取消</el-button>
<el-button type="primary" class="btn-confirm" @click="handleSavePermission" :loading="loading">确认应用</el-button>
</div>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { Close } from "@element-plus/icons-vue";
import baseSegmentMenu from "./baseSegmentMenu.vue";
import fieldPermissionManager from "./fieldPermissionManager.vue";
import { saveRolePermission,getRolePermission } from '@/api/stage/permission';
import { useDict } from '@/hooks/useDictData';
const { dicts,refresh } = useDict('permission_setting_status');
interface permissionProps {
permissions:Record<string,any>[];
}
defineOptions({ name: "PermissionDrawer" });
const props = defineProps({
checkAuth:{
type: Object,
default: ()=>({}),
}
});
const drawerVisible = defineModel("visible", { type: Boolean, default: false });
const activeTab = ref<string>("menu");
const currentModule = ref<number>(4); // 当前高亮的模块
const menuList = ref([]);
const loading = ref<boolean>(false);
// 动态展示当前人员名称
const personAuth = computed(() => {
return props.checkAuth.name;
});
// 动态的数据
const fieldsList = computed({
get:()=>{
const activeFields = [];
menuList.value.forEach(group => {
(group.children || []).forEach(row => {
if (row.selected) {
activeFields.push(row); // 将包含 fields 的整个对象传过去
}
});
});
return activeFields;
},
set:()=>{}
})
// 获取角色详情数据
const roleDetail = async (roleId) =>{
try {
const response = await getRolePermission(roleId);
menuList.value = response.map(group => {
const checkedGroup = (group.children || []).filter(c => c.selected);
const allCheckedGroup = (group.children || []).every(c => c.selected); //兼容后端全选后返回为false的问题
const checkedCount = checkedGroup.length;
return {
...group,
selected:allCheckedGroup,
isIndeterminate: checkedCount > 0 && checkedCount < group.children.length,
children:(group.children||[]).map(child=>{
const actions = child.selected ? child.operations.filter(action => action.selected).map(action => action.id) : [];
return {
...child,
isIndeterminate: actions.length > 0 && actions.length < child.operations.length,
actions,
};
})
};
});
} catch (error) {
console.log('error',error);
}
}
/**
* 非阻塞提取权限载荷
* @param {Array} menuList 原始响应式数据
*/
const extractPermissionPayload = async (menuList) => {
// 使用深拷贝,避免清洗过程中由于响应式追踪导致的 UI 卡顿
const sourceData = JSON.parse(JSON.stringify(menuList));
const result = [];
// 辅助函数处理单个功能节点Leaf Node
const formatLeafNode = (node) => {
const operationIds = (node.actions || []);
const fieldPermissions = (node.fieldGroups || []).map(group => ({
groupId: group.id,
permission: group.permission,
fields: (group.fields || []).map(field => ({
fieldId: field.id,
permission: field.permission
}))
}));
return {
leafNodeId: node.id,
operationIds: operationIds,
fieldPermissions: fieldPermissions
};
};
return new Promise((resolve) => {
let i = 0;
const chunk = () => {
const startTime = performance.now();
// 保持每一片执行时间在 16ms 以内(一帧的时间),确保不卡顿
while (i < sourceData.length && (performance.now() - startTime) < 16) {
const group = sourceData[i];
// 情况 A: 有二级菜单 (如商机管理)
if (group.children && group.children.length > 0) {
group.children.forEach(row => {
// 只要有选中的操作或二级被选中,就视为有效节点
if (row.selected || (row.actions && row.actions.length > 0)) {
result.push(formatLeafNode(row));
}
});
}
// 情况 B: 一级就是功能节点 (如团队管理无children有operations)
else {
if (group.selected || group.actions && group.actions.length > 0) {
result.push(formatLeafNode(group));
}
}
i++;
}
if (i < sourceData.length) {
requestAnimationFrame(chunk);
} else {
resolve(result);
}
};
chunk();
});
};
watch(()=>menuList.value,(val)=>{
// console.log('获取数据动态变化',val);
},{deep:true})
// 保存权限
const handleSavePermission = async () =>{
try {
loading.value = true;
const finalPayload = await extractPermissionPayload(menuList.value);
const res = await saveRolePermission({roleId:props.checkAuth.id,permissions:finalPayload});
ElMessage.success('操作成功');
drawerVisible.value = false;
} catch (error) {
console.log('save error',error);
} finally {
loading.value = false;
}
}
// 监听传入的id值的变化
watch(drawerVisible,(newVisible)=>{
if(newVisible){
const newRoleId = props.checkAuth.id;
roleDetail(newRoleId);
refresh();
}
})
</script>
<style lang="scss" scoped>
@use "./baseSegmentedPermission.scss" as *;
</style>

View File

@@ -1,12 +1,12 @@
<template>
<div class="">
<Comment />
<div class="personnel">
人员管理
</div>
</template>
<script setup lang="ts">
import Comment from "@/modules/Comment/index.vue";
import { reactive, ref, onMounted } from "vue";
defineOptions({ name: "Personnel" });
const drawer = ref(true);
</script>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
</style>

View File

@@ -3,16 +3,16 @@ import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
import { ElNotification } from "element-plus";
import { VITE_APP_BASE_API } from "../../config.js";
import TokenManager from "@/utils/storage";
import { getMockData, shouldUseMock } from "@/mock"; //mock数据信息
const tokenManager = TokenManager.getInstance();
const baseUrl = import.meta.env.MODE === "development" ? "/api" : VITE_APP_BASE_API;
const baseUrl =
import.meta.env.MODE === "development" ? "/api" : VITE_APP_BASE_API;
// 1. 锁和队列定义在类外部,确保全局唯一
let isRefreshing = false;
let requestsQueue: Array<(token: string) => void> = [];
// 登录接口 传递参数不一样
const AUTH_OAUTH2_TOKEN_URL = "/auth/oauth2/token";
const CLIENT_CREDENTIALS = "Basic " + window.btoa("mversion:secret");
const CLIENT_CREDENTIALS = "Basic " + window.btoa("mversion:H7mHCOfjhV2VqlR31OHi6ruVMeOQvluz");
class HttpRequest {
private instance: AxiosInstance;
@@ -127,13 +127,23 @@ class HttpRequest {
}
// 其它业务错误
ElNotification.error({ title: "提示", message: res.msg || "服务异常" });
ElNotification.error({
title: "提示",
dangerouslyUseHTMLString: true,
message: `${res.msg}<br/>错误码:${res.subCode}` || "服务异常",
});
return Promise.reject(res);
},
(error) => {
// 网络层错误处理
if (error.response?.status === 401) {
this.clearTokens();
} else {
if(this.isAuthEndpoint(error.config.url)){
ElNotification.error({ title: "提示", message: error.response.data.error || '未知错误' });
return;
}
ElNotification.error({ title: "提示", message: "服务异常" });
}
return Promise.reject(error);
}

View File

@@ -0,0 +1,33 @@
import { componentMap } from "./routeMap";
/**
* 组装函数:将后端原始嵌套树重组为前端要求的骨架结构
* @param backendTree 后端返回的原始数据 (包含 backend, business 等顶层节点的树)
*/
export const transformBackendTree = (backendTree: any[]): any[] => {
if (!backendTree || !Array.isArray(backendTree)) return [];
// 排序操作
const sortedTree = [...backendTree].sort((a, b) => {
const sortA = a.sort ?? 999; // 兜底值,防止没有 sort 字段
const sortB = b.sort ?? 999;
return sortA - sortB;
});
return sortedTree.map(item => {
// 2. 构造当前节点
const newNode: any = {
...item,
code: componentMap[item.code],
originalCode: item.code, // 保留一份原始 code 备用
};
// 3. 递归处理 children
if (item.children && item.children.length > 0) {
newNode.children = transformBackendTree(item.children);
} else {
newNode.children = null;
}
return newNode;
});
};

View File

@@ -4,12 +4,13 @@ import {
type RouteRecordRaw,
} from "vue-router";
import { useUserStore } from "@/store";
import { getRouteMenus } from "@/api";
import { getUserInfo } from "@/api";
import TokenManager from "@/utils/storage";
import Login from "@/pages/Login/index.vue";
import HomeView from "@/pages/Layout/index.vue";
import { transformBackendTree } from './generateFinalMenu';
import { mockBackendMenuData } from "@/mock/menu";
const tokenManager = TokenManager.getInstance();
// 基础路由(不需要权限验证)
const constantRoutes: RouteRecordRaw[] = [
@@ -30,7 +31,6 @@ const asyncRoutes: RouteRecordRaw[] = [
path: "/",
name: "Layout",
component: HomeView,
// redirect: '/home',
meta: {
requiresAuth: true,
},
@@ -85,7 +85,6 @@ const transformRoutes = (
routes: any[],
parentCode: string = ""
): RouteRecordRaw[] => {
console.log("transformRoutes", routes);
return routes.flatMap((route) => {
const fullCode = parentCode ? `${parentCode}/${route.code}` : route.code;
@@ -129,6 +128,29 @@ const transformRoutes = (
}
});
};
/**
* 递归查找路由配置中的第一个有效路径(叶子节点)
*/
const getFirstValidPath = (routes: RouteRecordRaw[], parentPath: string = "") => {
for (const route of routes) {
// 拼接当前层级的完整路径
let currentPath = route.path.startsWith("/")
? route.path
: `${parentPath}/${route.path}`.replace(/\/+/g, "/");
// 如果有子路由,继续递归,并将当前路径传下去
if (route.children && route.children.length > 0) {
const childPath = getFirstValidPath(route.children, currentPath);
if (childPath) return childPath;
}
// 如果是叶子节点(有组件),返回拼接好的完整路径
if (route.component) {
return currentPath;
}
}
return "/";
};
// 添加动态路由
const addDynamicRoutes = async () => {
@@ -140,31 +162,16 @@ const addDynamicRoutes = async () => {
}
try {
// 从后端获取路由菜单数据 (这边需要区分 后台的菜单 和用户的菜单)
let allRoutes:any[] = [];
if (userStore.isBackendUser) {
const backendResponse = await getRouteMenus();
allRoutes = [
{
code: "stage",
name: "管理中心",
icon: "",
meta:{
title:'管理中心'
},
children: backendResponse,
},
];
} else {
// TODO:获取用户端的数据信息
// response = await getUserMenus();
allRoutes = [];
}
const { modules:backendRawData } = await getUserInfo();
const allRoutes = transformBackendTree(backendRawData);
console.log('获取最终渲染的菜单数据格式:',allRoutes);
if (allRoutes) {
// 转换路由数据
const dynamicRoutes = transformRoutes(
Array.isArray(allRoutes) ? allRoutes : [allRoutes]
);
console.log('最终渲染的菜单数据:',dynamicRoutes)
// 将动态路由添加到 Layout 的 children 中
const layoutRoute = router
.getRoutes()
@@ -222,7 +229,7 @@ router.beforeEach(async (to, _from, next) => {
// 未登录,重定向到登录页
next({
path: "/login",
query: { redirect: to.fullPath }, // 保存当前路径,登录后可以跳转回来
query: { redirect: to.fullPath },
});
return;
}
@@ -232,15 +239,28 @@ router.beforeEach(async (to, _from, next) => {
try {
// 加载动态路由
await addDynamicRoutes();
// 路由加载完成后,重新导航到目标路由
next({ ...to, replace: true });
const redirect = to.query.redirect as string;
if (to.path === "/" || to.path === "/login") {
const redirect = to.query.redirect as string;
const firstPath = getFirstValidPath(userStore.routes);
const targetPath = (redirect && redirect !== '/') ? redirect : firstPath;
next({ path: targetPath, replace: true });
} else {
// 路由加载完成后,重新导航到目标路由
next({ ...to, replace: true });
}
} catch (error) {
console.error("Route loading error:", error);
next("/login");
}
} else {
// 路由已加载,直接放行
next();
if (to.path === "/") {
const firstPath = getFirstValidPath(userStore.routes);
next({ path: firstPath, replace: true });
} else {
next();
}
}
});

41
src/router/routeMap.ts Normal file
View File

@@ -0,0 +1,41 @@
const stageRoute = {
backend: "stage",
flow: "flow",
origanization: "origanization",
personnel: "personnel",
permission: "permission",
dict: "dict",
}
const businessRoute = {
business: "businessManage",
"business.customer": "customer",
"business.game_studio":"gameStudios",
"business.opportunity":"businessOpport",
}
const contractRoute = {
contract: "contractManage",
}
const teamRoute = {
team: "teamManage",
}
const financeRoute = {
finance: "financeManage",
}
const recruitmentRoute = {
recruitment: "recruitmentManage",
}
export const componentMap: Record<string, string> = {
...stageRoute,
...businessRoute,
...contractRoute,
...teamRoute,
...financeRoute,
...recruitmentRoute
};

View File

@@ -3,75 +3,77 @@
html,
body {
height: 100%;
margin: 0;
padding: 0;
height: 100%;
margin: 0;
padding: 0;
}
#app {
height: inherit;
height: inherit;
}
:root {
--mj-menu-header-height:#{$mj-menu-header-height};
--mj-border-color:#{$mj-border-color};
--mj-padding-standard:#{$mj-padding-standard};
--mj-popper-radius: 8px;
--el-color-primary:#2b65f6;
--mj-menu-header-height:#{$mj-menu-header-height};
--mj-border-color:#{$mj-border-color};
--mj-padding-standard:#{$mj-padding-standard};
--mj-popper-radius: 8px;
--el-color-primary: #2b65f6;
}
// popover 筛选框的全局样式
.filter-popper.el-popover.el-popper {
--el-popover-padding: 0;
border-radius: var(--mj-popper-radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
--el-popover-padding: 0;
border-radius: var(--mj-popper-radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
// 全局重element相关样式
// 全局重element相关样式
.mj-input-form {
.el-input {
--el-border-radius-base: 10px;
--el-border-color: #E2E8F0;
}
.el-input {
--el-border-radius-base: 10px;
--el-border-color: #E2E8F0;
}
}
// 搜索框动画
.search-dict-input {
--default-width: 160px;
--max-width: 224px;
width: var(--default-width);
transition: width 0.3s ease;
.search-auto-expand-input {
--default-width: 160px;
--max-width: 224px;
width: var(--default-width);
transition: width 0.3s ease;
&:focus-within {
width: var(--max-width);
}
&:focus-within {
width: var(--max-width);
}
.auto-expand-input {
--el-input-border-radius: 4px;
width: 100%;
}
.auto-expand-input {
--el-input-border-radius: 4px;
width: 100%;
}
}
// 字典状态全局样式
.mj-status-dot {
cursor: pointer;
&::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--data-status-color);
margin-right: 8px;
vertical-align: middle;
}
cursor: pointer;
&::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--data-status-color);
margin-right: 8px;
vertical-align: middle;
}
}
// 筛选框全局样式内容
// 筛选框内容的全局样式内容
.mj-filter-content {
min-width: 380px;
background: #fff;
@@ -101,7 +103,8 @@ body {
.filter-item {
margin-bottom: 20px;
label{
label {
display: block;
font-size: 14px;
color: #909399;
@@ -111,7 +114,7 @@ body {
}
.custom-select {
--el-border-color:transparent;
--el-border-color: transparent;
width: 100%;
background-color: #f5f7fa;
box-shadow: none;
@@ -127,9 +130,79 @@ body {
font-weight: bold;
border-radius: 6px;
margin-top: 10px;
&:hover {
background-color: #407eff;
border-color: #407eff;
}
}
}
// 全局单行省略样式
.mj-ellipsis-one-line {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 卡片的全局样式
.mj-card-container {
display: grid;
gap: 16px;
width: 100%;
// 默认:一行 5 列
grid-template-columns: repeat(5, 1fr);
// 1400px 以下:觉得 5 个太挤,切到一行 4 列
@media (max-width: 1400px) {
grid-template-columns: repeat(4, 1fr);
}
// 1100px 以下 (iPad横屏区间):一行 3 列
@media (max-width: 1100px) {
grid-template-columns: repeat(3, 1fr);
}
// 768px 以下 (iPad竖屏):一行 2 列
@media (max-width: 768px) {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
// 480px 以下 (手机端):一行 1 列
@media (max-width: 480px) {
grid-template-columns: repeat(1, 1fr);
}
}
// select下拉搜索loading全局公用样式
.mj-select-dropdown-loading {
cursor: default;
background-color: transparent;
.status-container {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
color: #909399;
font-size: 13px;
height: 34px;
}
}
.mj-ellipsis-cell {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-all;
display: block;
cursor: default;
}

View File

@@ -1,15 +1,14 @@
// 当前样式表修改element 全局的样式
// 标砖抽屉样式
.standard-ui-drawer{
.el-drawer__header{
.standard-ui-drawer {
.el-drawer__header {
position: relative;
margin-bottom: 0;
padding-bottom: var(--el-drawer-padding-primary);
&::after{
content:'';
&::after {
content: '';
position: absolute;
left: var(--el-drawer-padding-primary);
bottom: 0;
@@ -20,51 +19,185 @@
}
}
.standard-ui-back-drawer{
.standard-ui-back-drawer {
@extend .standard-ui-drawer;
.el-drawer__header{
.el-drawer__header {
background-color: #FBFCFD;
&::after{
&::after {
width: 100%;
left: 0;
}
}
.el-drawer__body{
.customer-drawer-header {
padding: 20px 24px 15px;
border-bottom: 1px solid #f0f0f0;
position: relative;
&::before {
content: "";
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 16px;
background-color: #409eff;
border-radius: 2px;
}
.title-row {
display: flex;
align-items: center;
margin-left: 20px;
.title {
font-size: 16px;
font-weight: bold;
flex: 1;
}
.close-icon {
cursor: pointer;
color: #909399;
&:hover {
color: #409eff;
}
}
}
.sub-title {
margin: 2px 0 0 20px;
font-size: 13px;
color: #909399;
.sub-roles{
color: var(--el-color-primary);
}
}
}
.el-drawer__body {
padding: 0;
}
.el-drawer__footer{
.el-drawer__footer {
background-color: #FBFCFD;
border-top: 1px solid #E5E7EB;
.custom-flat-drawer-footer {
--black-bg-hover-color:#2b364a;
--black-bg-color:#1d2635;
display: flex;
justify-content: space-between;
align-items: center;
.btn-confirm {
background-color: var(--black-bg-color);
border-color: var(--black-bg-color);
padding: 10px 24px;
&:hover {
background-color: var(--black-bg-hover-color);
border-color: var(--black-bg-hover-color);
}
}
}
}
}
// 标注弹窗样式
.standard-ui-dialog{
&.el-dialog{
--el-dialog-inset-padding-primary:16px;
--el-dialog-padding-primary:0;
--el-dialog-border-radius:16px;
--el-dialog-bg-header-footer:#FBFCFD;
--el-dialog-border-header-footer-color:#E5E7EB;
.standard-ui-dialog {
&.el-dialog {
--el-dialog-inset-padding-primary: 16px;
--el-dialog-padding-primary: 0;
--el-dialog-border-radius: 16px;
--el-dialog-bg-header-footer: #FBFCFD;
--el-dialog-border-header-footer-color: #E5E7EB;
padding: var(--el-dialog-padding-primary);
}
.el-dialog__header{
.el-dialog__header {
border-bottom: 1px solid var(--el-dialog-border-header-footer-color);
background-color:var(--el-dialog-bg-header-footer);
background-color: var(--el-dialog-bg-header-footer);
padding: var(--el-dialog-inset-padding-primary);
border-radius: var(--el-dialog-border-radius) var(--el-dialog-border-radius) 0 0;
}
.el-dialog__headerbtn{
.el-dialog__headerbtn {
height: 60px;
}
.el-dialog__body{
.el-dialog__body {
padding: var(--el-dialog-inset-padding-primary);
}
.el-dialog__footer{
.el-dialog__footer {
padding: var(--el-dialog-inset-padding-primary);
background-color: var(--el-dialog-bg-header-footer);
border-top: 1px solid var(--el-dialog-border-header-footer-color);
border-radius: 0 0 var(--el-dialog-border-radius) var(--el-dialog-border-radius);
}
}
// 扁平化dialog样式
.standard-ui-flat-dialog {
&.el-dialog {
--el-dialog-inset-padding-primary: 16px;
--el-dialog-padding-primary: 0;
--el-dialog-border-radius: 4px;
--el-dialog-bg-header-footer: #fcfdfe;
--el-dialog-border-header-footer-color: #f0f2f5;
--blue-block-color: #1661ff;
padding: var(--el-dialog-padding-primary);
}
.el-dialog__header {
padding: var(--el-dialog-inset-padding-primary);
border-bottom: 1px solid var(--el-dialog-border-header-footer-color);
.el-dialog__title{
position: relative;
padding-left: 16px;
font-size: 14px;
&::before{
content: '';
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 16px;
background-color: var(--blue-block-color);
border-radius: 2px;
left: 0;
}
}
}
.el-dialog__body {
padding: 32px;
}
.el-dialog__footer {
text-align: right;
background-color: var(--el-dialog-bg-header-footer);
border-top: 1px solid var(--el-dialog-border-header-footer-color);
border-radius: 0 0 var(--el-dialog-border-radius) var(--el-dialog-border-radius);
padding: var(--el-dialog-inset-padding-primary);
}
}
// 扁平化遮罩层样式 (drawer-dialog 的遮罩层样式)
.standard-overlay-dialog-flat {
--el-overlay-color-lighter: rgba(0, 0, 0, .12);
backdrop-filter: blur(8px);
}
.standard-select-dropdown-height{
.el-select-dropdown__wrap{
min-height: 240px;
}
}

View File

@@ -1,9 +1,16 @@
import { useUserStore } from "@/store";
function permissionDirective(el: HTMLElement,binding:any) {
import { throttle } from "lodash-es";
function permissionDirective(el: HTMLElement, binding: any) {
const appStore = useUserStore();
const userPermissions = appStore.role;
let requiredPermissions = binding.value;
// 使用特殊值 '*' 表示跳过权限检查
const isSkipCheck =
requiredPermissions === "*" ||
(Array.isArray(requiredPermissions) && requiredPermissions.includes("*"));
if (isSkipCheck) {
return;
}
if (typeof requiredPermissions === "string") {
requiredPermissions = [requiredPermissions];
}
@@ -25,12 +32,52 @@ const permission = {
},
};
const selectMore = {
mounted(el, binding) {
const callback = binding.value;
const onScroll = throttle(
function () {
const { scrollTop, scrollHeight, clientHeight } = this;
const isBottom = scrollHeight - scrollTop <= clientHeight + 10;
if (isBottom) {
callback();
}
},
200,
{ trailing: true }
);
// 关键:轮询寻找滚动容器,因为 mounted 时下拉框可能还没渲染
const interval = setInterval(() => {
// 兼容 teleport 情况,如果 el 找不到,尝试寻找 popper
const wrapper = el.querySelector(".el-scrollbar__wrap");
if (wrapper) {
wrapper.addEventListener("scroll", onScroll);
el._scrollWrapper = wrapper;
el._onScroll = onScroll;
clearInterval(interval);
}
}, 150);
setTimeout(() => clearInterval(interval), 10000);
},
beforeUnmount(el) {
if (el._scrollWrapper && el._onScroll) {
el._scrollWrapper.removeEventListener("scroll", el._onScroll);
el._onScroll.cancel?.();
}
},
};
const directives = {
permission,
selectMore,
};
export default (app: any) => {
Object.keys(directives).forEach((key) => {
app.directive(key, directives[key]);
});
};
};

View File

@@ -15,13 +15,16 @@ export const usePermission = () => {
const userPermissions = appStore.role;
const permissionArray = Array.isArray(requiredPermissions) ? requiredPermissions : [requiredPermissions];
// 为*跳过权限校验
const isSkipCheck = requiredPermissions === "*" || (permissionArray && permissionArray.includes("*"));
if (isSkipCheck) {return true;}
const hasPermission = permissionArray.some((permission) =>
userPermissions.includes(permission)
);
return hasPermission;
};
return { checkPermission };
};

View File

@@ -2,11 +2,16 @@ class TokenManager {
private static instance: TokenManager | null = null;
private storage: Storage;
private constructor(storageType: 'localStorage' | 'sessionStorage' = 'localStorage') {
this.storage = storageType === 'localStorage' ? localStorage : sessionStorage;
private constructor(
storageType: "localStorage" | "sessionStorage" = "localStorage"
) {
this.storage =
storageType === "localStorage" ? localStorage : sessionStorage;
}
public static getInstance(storageType: 'localStorage' | 'sessionStorage' = 'localStorage'): TokenManager {
public static getInstance(
storageType: "localStorage" | "sessionStorage" = "localStorage"
): TokenManager {
if (!TokenManager.instance) {
TokenManager.instance = new TokenManager(storageType);
}
@@ -25,9 +30,13 @@ class TokenManager {
this.storage.removeItem(key);
}
clearStorage(): void {
this.storage.clear();
const PROTECT_PREFIX = "keep_";
Object.keys(this.storage).forEach((key) => {
if (!key.startsWith(PROTECT_PREFIX)) {
this.storage.removeItem(key);
}
});
}
}
export default TokenManager;
export default TokenManager;

6
src/utils/svgIcon.ts Normal file
View File

@@ -0,0 +1,6 @@
export const svgIcons: Record<string, string> = {
'custom-building': `<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M853.333333 426.666667H725.333333v298.666666H298.666667V426.666667H170.666667v384c0 23.466667 19.2 42.666667 42.666666 42.666667h597.333334c23.466667 0 42.666667-19.2 42.666666-42.666667v-384z m-512-128c0-117.333333 96-213.333333 213.333333-213.333334s213.333333 96 213.333334 213.333334H341.333333z"/></svg>`,
'custom-company': `<svg class="icon-svg" viewBox="0 0 1024 1024"><path fill="currentColor" d="M288 128a32 32 0 0 0-32 32v704a32 32 0 0 0 64 0V544h448l-64-160 64-160H320V160a32 32 0 0 0-32-32zM320 288h352l-32 80 32 80H320V288z"/></svg>`,
'custom-department': `<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M853.333333 426.666667H725.333333v298.666666H298.666667V426.666667H170.666667v384c0 23.466667 19.2 42.666667 42.666666 42.666667h597.333334c23.466667 0 42.666667-19.2 42.666666-42.666667v-384z"/></svg>`,
// 你可以添加更多 SVG 图标
}

View File

@@ -8,6 +8,6 @@ export function getImageUrl(url: string) {
// 设置index前缀 如001 010 100种 默认兼容3位数
export const formatIndex = (index,padNum=3) => {
export const formatIndex = (index,padNum?:number=3) => {
return String(index + 1).padStart(padNum, '0');
};

View File

@@ -4,6 +4,7 @@ import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
import { resolve } from "path";
import vueJsx from '@vitejs/plugin-vue-jsx'
import { VITE_APP_BASE_API, VITE_PUBLIC_PATH } from './config.js';
// https://vite.dev/config/
@@ -23,6 +24,7 @@ export default defineConfig(({ mode }) => {
resolvers: [ElementPlusResolver()],
}),
vue(),
vueJsx()
],
resolve: {
alias: {

17
vitest.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
// 关键:模拟浏览器环境,否则无法测试 DOM 和滚动
environment: 'happy-dom',
// 支持全局 API 如 describe, it, expect
globals: true,
// 路径别名(确保能找到你的 @/components
alias: {
'@': resolve(__dirname, './src')
}
},
})