add:新增商机管理-客户管理页面

This commit is contained in:
liangdong
2026-01-14 19:23:22 +08:00
parent c77ba236e7
commit d722f5cbc0
17 changed files with 2155 additions and 235 deletions

163
Jenkinsfile vendored
View File

@@ -28,7 +28,7 @@ pipeline {
extensions : [ extensions : [
[$class: 'RelativeTargetDirectory', relativeTargetDir: 'docker'], [$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 "echo '拷贝Dockerfile文件'"
sh "cp ./docker/front/Dockerfile ." sh "cp ./docker/front/Dockerfile ."
@@ -49,9 +49,9 @@ pipeline {
stage('推送镜像到镜像仓库') { stage('推送镜像到镜像仓库') {
steps { steps {
sh "echo '改镜像标签'" 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 "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('保存到发布版本目录'){ // stage('保存到发布版本目录'){
when { // when {
expression { env.branch == "main" } // expression { env.branch == "main" }
} // }
steps { // steps {
sh "mkdir -p /projects/$groupName/$targetVersion/$projectName" // sh "mkdir -p /projects/$groupName/$targetVersion/$projectName"
sh "rm -f /projects/$groupName/$targetVersion/$projectName/*" // sh "rm -f /projects/$groupName/$targetVersion/$projectName/*"
sh "cp project/* /projects/$groupName/$targetVersion/$projectName/" // sh "cp project/* /projects/$groupName/$targetVersion/$projectName/"
script { // script {
if(Boolean.parseBoolean(proddeploy)) { // if(Boolean.parseBoolean(proddeploy)) {
sshPublisher( // sshPublisher(
continueOnError: false, // continueOnError: false,
failOnError: true, // failOnError: true,
publishers: [ // publishers: [
sshPublisherDesc( // sshPublisherDesc(
configName: env.branchConfig, // configName: env.branchConfig,
transfers: [ // transfers: [
sshTransfer( // sshTransfer(
cleanRemote: false, // cleanRemote: false,
excludes: '', // excludes: '',
execCommand: [ // execCommand: [
"echo 定位到项目位置", // "echo 定位到项目位置",
"cd /usr/local/fengchaoit/$projectName", // "cd /usr/local/fengchaoit/$projectName",
"echo 授予启动脚本权限", // "echo 授予启动脚本权限",
"chmod +x ./apprun.sh", // "chmod +x ./apprun.sh",
"echo 停止正在运行服务", // "echo 停止正在运行服务",
"./apprun.sh remove", // "./apprun.sh remove",
"echo 启动新构建服务", // "echo 启动新构建服务",
"./apprun.sh restart" // "./apprun.sh restart"
].join('\n'), // ].join('\n'),
execTimeout: 120000, // execTimeout: 120000,
flatten: false, // flatten: false,
makeEmptyDirs: false, // makeEmptyDirs: false,
noDefaultExcludes: false, // noDefaultExcludes: false,
patternSeparator: '[, ]+', // patternSeparator: '[, ]+',
remoteDirectory: "/usr/local/fengchaoit/$projectName", // remoteDirectory: "/usr/local/fengchaoit/$projectName",
remoteDirectorySDF: false, // remoteDirectorySDF: false,
removePrefix: 'project', // removePrefix: 'project',
sourceFiles: 'project/*' // sourceFiles: 'project/*'
) // )
], // ],
usePromotionTimestamp: false, // usePromotionTimestamp: false,
useWorkspaceInPromotion: false, // useWorkspaceInPromotion: false,
verbose: true // verbose: true
) // )
] // ]
) // )
} // }
} // }
} // }
} // }
stage('部署项目到服务器'){ stage('部署项目到服务器'){
@@ -138,7 +138,7 @@ pipeline {
excludes: '', excludes: '',
execCommand: [ execCommand: [
"echo 定位到项目位置", "echo 定位到项目位置",
"cd /usr/local/fengchaoit/WorkSpace/$projectName", "cd /opt/mingjiang/$projectName",
"echo 授予启动脚本权限", "echo 授予启动脚本权限",
"chmod +x ./apprun.sh", "chmod +x ./apprun.sh",
"echo 停止正在运行服务", "echo 停止正在运行服务",
@@ -151,7 +151,7 @@ pipeline {
makeEmptyDirs: false, makeEmptyDirs: false,
noDefaultExcludes: false, noDefaultExcludes: false,
patternSeparator: '[, ]+', patternSeparator: '[, ]+',
remoteDirectory: "/usr/local/fengchaoit/WorkSpace/$projectName", remoteDirectory: "/opt/mingjiang/$projectName",
remoteDirectorySDF: false, remoteDirectorySDF: false,
removePrefix: 'project', removePrefix: 'project',
sourceFiles: 'project/*' sourceFiles: 'project/*'
@@ -174,28 +174,37 @@ pipeline {
} }
} }
post { post {
success { // success {
script { // script {
if(env.branch == "main"){ // 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" // 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$)/) { // } 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" // 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 { // } 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" // 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 { // 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" // 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 { // 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" // 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 { cleanup {
sh "echo '清理无用空镜像'" sh '''
sh "docker image prune -f" echo "开始清理已停止容器......"
sh "var=\$(docker ps -a -q --filter \"status=exited\");if [ -n \"\$var\" ];then docker rm \$var; fi" # 1. 清理已停止的容器
cleanWs() # -r 参数防止 xargs 在输入为空时报错
docker ps -a -q --filter "status=exited" | xargs -r docker rm
echo "清理已停止容器完成......"
echo "清理无用空镜像......"
# 2. 清理无用的空镜像 (dangling images)
docker image prune -f
echo "无用空镜像清理完成"
'''
deleteDir()
} }
} }

18
components.d.ts vendored
View File

@@ -13,7 +13,9 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AutoTooltip: typeof import('./src/components/autoTooltip/index.vue')['default'] AutoTooltip: typeof import('./src/components/autoTooltip/index.vue')['default']
CardItem: typeof import('./src/components/cardItem/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'] CollapseHeader: typeof import('./src/components/collapseHeader/index.vue')['default']
Comment: typeof import('./src/components/comment/index.vue')['default'] Comment: typeof import('./src/components/comment/index.vue')['default']
CommonFilter: typeof import('./src/components/commonFilter/index.vue')['default'] CommonFilter: typeof import('./src/components/commonFilter/index.vue')['default']
@@ -24,6 +26,7 @@ declare module 'vue' {
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxButton: typeof import('element-plus/es')['ElCheckboxButton']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer'] ElContainer: typeof import('element-plus/es')['ElContainer']
@@ -80,6 +83,7 @@ declare module 'vue' {
NodeFlow: typeof import('./src/components/nodeFlow/index.vue')['default'] NodeFlow: typeof import('./src/components/nodeFlow/index.vue')['default']
OverflowTabs: typeof import('./src/components/overflowTabs/index.vue')['default'] OverflowTabs: typeof import('./src/components/overflowTabs/index.vue')['default']
PageForm: typeof import('./src/components/pageForm/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'] ProTable: typeof import('./src/components/proTable/index.vue')['default']
ProTablev2: typeof import('./src/components/proTable/proTablev2.vue')['default'] ProTablev2: typeof import('./src/components/proTable/proTablev2.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
@@ -89,6 +93,9 @@ declare module 'vue' {
StandardMenu: typeof import('./src/components/standardMenu/index.vue')['default'] StandardMenu: typeof import('./src/components/standardMenu/index.vue')['default']
StandMenu: typeof import('./src/components/standMenu/index.vue')['default'] StandMenu: typeof import('./src/components/standMenu/index.vue')['default']
UserSelector: typeof import('./src/components/userSelector/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'] Xxx: typeof import('./src/components/comment/xxx.vue')['default']
Xxxx: typeof import('./src/components/xxxx/index.vue')['default'] Xxxx: typeof import('./src/components/xxxx/index.vue')['default']
} }
@@ -101,7 +108,9 @@ declare module 'vue' {
// For TSX support // For TSX support
declare global { declare global {
const AutoTooltip: typeof import('./src/components/autoTooltip/index.vue')['default'] const AutoTooltip: typeof import('./src/components/autoTooltip/index.vue')['default']
const CardItem: typeof import('./src/components/cardItem/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 CollapseHeader: typeof import('./src/components/collapseHeader/index.vue')['default']
const Comment: typeof import('./src/components/comment/index.vue')['default'] const Comment: typeof import('./src/components/comment/index.vue')['default']
const CommonFilter: typeof import('./src/components/commonFilter/index.vue')['default'] const CommonFilter: typeof import('./src/components/commonFilter/index.vue')['default']
@@ -112,6 +121,7 @@ declare global {
const ElButton: typeof import('element-plus/es')['ElButton'] const ElButton: typeof import('element-plus/es')['ElButton']
const ElCard: typeof import('element-plus/es')['ElCard'] const ElCard: typeof import('element-plus/es')['ElCard']
const ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] 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 ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
const ElCol: typeof import('element-plus/es')['ElCol'] const ElCol: typeof import('element-plus/es')['ElCol']
const ElContainer: typeof import('element-plus/es')['ElContainer'] const ElContainer: typeof import('element-plus/es')['ElContainer']
@@ -168,6 +178,7 @@ declare global {
const NodeFlow: typeof import('./src/components/nodeFlow/index.vue')['default'] const NodeFlow: typeof import('./src/components/nodeFlow/index.vue')['default']
const OverflowTabs: typeof import('./src/components/overflowTabs/index.vue')['default'] const OverflowTabs: typeof import('./src/components/overflowTabs/index.vue')['default']
const PageForm: typeof import('./src/components/pageForm/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 ProTable: typeof import('./src/components/proTable/index.vue')['default']
const ProTablev2: typeof import('./src/components/proTable/proTablev2.vue')['default'] const ProTablev2: typeof import('./src/components/proTable/proTablev2.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink'] const RouterLink: typeof import('vue-router')['RouterLink']
@@ -177,6 +188,9 @@ declare global {
const StandardMenu: typeof import('./src/components/standardMenu/index.vue')['default'] const StandardMenu: typeof import('./src/components/standardMenu/index.vue')['default']
const StandMenu: typeof import('./src/components/standMenu/index.vue')['default'] const StandMenu: typeof import('./src/components/standMenu/index.vue')['default']
const UserSelector: typeof import('./src/components/userSelector/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 Xxx: typeof import('./src/components/comment/xxx.vue')['default']
const Xxxx: typeof import('./src/components/xxxx/index.vue')['default'] const Xxxx: typeof import('./src/components/xxxx/index.vue')['default']
} }

View File

@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"test": "vitest",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -13,8 +14,11 @@
"@micro-zoe/micro-app": "^1.0.0-rc.28", "@micro-zoe/micro-app": "^1.0.0-rc.28",
"@vitejs/plugin-vue-jsx": "^5.1.3", "@vitejs/plugin-vue-jsx": "^5.1.3",
"@vue-flow/core": "^1.48.1", "@vue-flow/core": "^1.48.1",
"@vue/test-utils": "^2.4.6",
"element-plus": "^2.13.0", "element-plus": "^2.13.0",
"happy-dom": "^20.1.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vitest": "^4.0.17",
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-i18n": "^11.2.7", "vue-i18n": "^11.2.7",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"

661
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,14 @@
<template> <template>
<div class="mj-card-container mj-grid-container"> <div class="mj-grid-container">
<div <div class="mj-card-item" @click="$emit('card-click', item)">
class="mj-card-item" <slot name="card-prefix" :item="item">
v-for="(card, index) in list"
:key="index"
@click="cardItemClick(card, index)"
>
<slot name="cardCover" :item="card">
<div class="mj-card-standard-tip" :style="standardTopStyle"></div>
</slot> </slot>
<div class="mj-card-standard-content"> <div class="mj-card-standard-content">
<slot name="content" :item="card" :index="index"></slot> <slot name="content" :item="item"></slot>
</div> </div>
<div v-if="$slots.actions" class="mj-card-actions"> <div v-if="$slots.actions" class="mj-card-actions">
<slot name="actions" :item="card"></slot> <slot name="actions" :item="item"></slot>
</div> </div>
</div> </div>
</div> </div>
@@ -24,28 +17,21 @@
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: "CardItem" }); defineOptions({ name: "CardItem" });
// item list
interface Props { interface Props {
list: any[]; item: any;
standardTopStyle?: string | Record<string, any>; standardTopStyle?: string | Record<string, any>;
} }
const props = withDefaults(defineProps<Props>(), { const props = defineProps<Props>();
list: () => [],
standardTopStyle: "",
});
const emits = defineEmits<{ const emits = defineEmits<{
(e: "card-click", payload: { item: any; index: number }): void; (e: "card-click", item: any): void;
}>(); }>();
const cardItemClick = (item: any, index: number) => {
emits("card-click", { item, index });
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.mj-grid-container { .mj-grid-container {
//
.mj-card-item { .mj-card-item {
--radius: 12px; --radius: 12px;
--primary-color: #409eff; --primary-color: #409eff;
@@ -86,4 +72,4 @@ const cardItemClick = (item: any, index: number) => {
} }
} }
} }
</style> </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

@@ -1,82 +1,112 @@
<template> <template>
<div class="mj-filter-group"> <div class="mj-filter-group">
<div :class="className ? className : 'mj-icon-container'"> <div :class="className ? className : 'mj-icon-container'">
<el-popover <div class="mj-icon-warp" @click.stop="openMenu($event, null)">
ref="filterPopover" <div class="mj-icon-item" title="筛选">
trigger="click" <el-icon><Filter /></el-icon>
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> </div>
</el-popover> <slot name="filterLabel"></slot>
<div class="mj-icon-item" title="下载" @click="onDownload" v-if="download"> </div>
<div
class="mj-icon-item"
title="下载"
@click="onDownload"
v-if="download"
>
<el-icon><Download /></el-icon> <el-icon><Download /></el-icon>
</div> </div>
</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> </div>
</template> </template>
<script setup> <script setup lang="ts">
const filterPopover = ref(null); 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";
// 定义事件:重置、应用、下载 const emit = defineEmits(["download", "on-hide"]);
defineEmits(["download",'on-hide']);
const props = defineProps({
defineProps({ download: {
download:{ type: Boolean,
type:Boolean, default: true,
default:true
}, },
className:{ className: {
type:String, type: String,
default:'' default: "",
}
})
defineExpose({
close() {
filterPopover.value?.hide()
}, },
}); });
// 使用 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 = () => { const onDownload = () => {
// @ts-ignore (确保你项目中已按需引入 ElMessage)
ElMessage.warning("功能开发中..."); ElMessage.warning("功能开发中...");
emit("download"); emit("download");
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
// 保留你原本的所有样式逻辑
.mj-icon-container { .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; display: inline-flex;
align-items: center; align-items: center;
padding: 2px 4px; padding: 2px 4px;
border-radius: 4px; border-radius: var(--radius);
background-color: #fff; background-color: var(--bg-color);
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); box-shadow: var(--shadow);
.mj-icon-warp{ border: 1px solid var(--border-color);
.mj-icon-warp {
display: flex; display: flex;
align-items: center; align-items: center;
padding-right: 3px; padding-right: 3px;
font-size: 12px; font-size: 12px;
color:#45556C; color: #45556c;
cursor: pointer; cursor: pointer;
} }
.mj-icon-item { .mj-icon-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -89,21 +119,22 @@ const onDownload = () => {
transition: all 0.2s; transition: all 0.2s;
border-radius: 4px; border-radius: 4px;
&:hover { &:hover,
background-color: #f5f7fa; &:active {
color: #409eff; background-color: var(--hover-color);
color: var(--el-color-primary);
} }
} }
} }
.mj-icon-level-container{ .mj-icon-level-container {
@extend .mj-icon-container; @extend .mj-icon-container;
border-radius: 10px; border-radius: 10px;
box-shadow: none; box-shadow: none;
border: 1px solid #E2E8F0; border: 1px solid #e2e8f0;
padding: 0 4px; padding: 0 4px;
.mj-icon-item{ .mj-icon-item {
width: 30px; width: 30px;
height: 30px; height: 30px;
} }

View File

@@ -52,11 +52,9 @@
:bgColor="'var(--mj-avatar-bg)'" :bgColor="'var(--mj-avatar-bg)'"
:avatarTextColor="'var(--mj-text-color)'" :avatarTextColor="'var(--mj-text-color)'"
/> />
<el-tooltip :content="item.name"> <div class="select-item-name">
<div class="select-item-name mj-ellipsis-one-line"> <AutoTooltip :content="item.name"/>
{{ item.name }} </div>
</div>
</el-tooltip>
</div> </div>
</el-option> </el-option>
<el-option <el-option
@@ -129,7 +127,7 @@ import NameAvatar from "@/components/NameAvatar/index.vue";
import { getEmployeeList } from "@/api"; import { getEmployeeList } from "@/api";
import { useSelectLoadMore } from "@/hooks/useSelectLoadMore"; import { useSelectLoadMore } from "@/hooks/useSelectLoadMore";
import { useLocalManager } from "@/hooks/useLocalManager"; import { useLocalManager } from "@/hooks/useLocalManager";
import AutoTooltip from "@/components/autoTooltip/index.vue";
const { const {
options, options,
remoteLoading, remoteLoading,

View File

@@ -7,6 +7,7 @@
'--avatar-text-size': fontSize, '--avatar-text-size': fontSize,
'--avatar-text-color': avatarTextColor, '--avatar-text-color': avatarTextColor,
}" }"
v-bind="$attrs"
class="mj-name-avatar" class="mj-name-avatar"
> >
<span v-if="!src" class="avatar-text">{{ displayText }}</span> <span v-if="!src" class="avatar-text">{{ displayText }}</span>

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

@@ -41,7 +41,14 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { ref, computed, onMounted, onUnmounted, nextTick } from "vue"; import { ref, computed, onMounted, onUnmounted, nextTick } from "vue";
import { ElTag, ElButton, ElText, ElTooltip } from "element-plus"; import {
ElTag,
ElButton,
ElText,
ElTooltip,
ElCheckbox,
useLocale,
} from "element-plus";
import { debounce } from "lodash-es"; import { debounce } from "lodash-es";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { DictManage } from "@/dict"; import { DictManage } from "@/dict";
@@ -54,6 +61,16 @@ const props = defineProps({
requestApi: { type: Function, required: true }, requestApi: { type: Function, required: true },
initParam: { type: Object, default: () => ({}) }, initParam: { type: Object, default: () => ({}) },
pageSize: { type: Number, default: 20 }, pageSize: { type: Number, default: 20 },
selection: { type: Boolean, default: false }, // 是否开启多选
selectionConfig:{
type: Object,
default:()=>({
width: 50,
fixed: false,
align: "center",
key: "selection"
})
}
}); });
// 内部数据状态 // 内部数据状态
@@ -64,17 +81,31 @@ const innerData = ref<any[]>([]);
const total = ref(0); const total = ref(0);
const pageNo = ref(1); const pageNo = ref(1);
const tableSize = ref({ width: 0, height: 400 }); const tableSize = ref({ width: 0, height: 400 });
const selectedKeys = ref<Set<string | number>>(new Set());
const isAllSelectedMode = ref(false); //全选模式
const noMore = computed(() => { const noMore = computed(() => {
if (total.value <= 0) return false; if (total.value <= 0) return false;
const isReachTotal = innerData.value.length >= total.value; const isReachTotal = innerData.value.length >= total.value;
return isReachTotal; return isReachTotal;
}); });
const SelectionCell = (props: {
value: boolean;
intermediate?: boolean;
onChange: (val: any) => void;
}) => (
<ElCheckbox
modelValue={props.value}
indeterminate={props.intermediate}
onChange={props.onChange}
/>
);
// --- 1. 核心渲染工厂 (内置常用业务组件) --- // --- 1. 核心渲染工厂 (内置常用业务组件) ---
// 定义一个内部小组件处理溢出逻辑 // 定义一个内部小组件处理溢出逻辑
const OverflowTooltip = defineComponent({ const OverflowTooltip = defineComponent({
props: { props: {
val: { type: String, default: "" } val: { type: String, default: "" },
}, },
setup(props) { setup(props) {
const isOverflow = ref(false); const isOverflow = ref(false);
@@ -83,7 +114,8 @@ const OverflowTooltip = defineComponent({
const checkOverflow = () => { const checkOverflow = () => {
if (textRef.value) { if (textRef.value) {
// 计算溢出 // 计算溢出
isOverflow.value = textRef.value.scrollWidth > textRef.value.clientWidth; isOverflow.value =
textRef.value.scrollWidth > textRef.value.clientWidth;
} }
}; };
@@ -103,11 +135,11 @@ const OverflowTooltip = defineComponent({
> >
{props.val} {props.val}
</div> </div>
) ),
}} }}
</ElTooltip> </ElTooltip>
); );
} },
}); });
const RenderFactory = { const RenderFactory = {
// 状态点/标签 // 状态点/标签
@@ -156,7 +188,7 @@ const RenderFactory = {
ellipsis: (scope: any, col: any) => { ellipsis: (scope: any, col: any) => {
const val = scope.rowData[col.prop] ?? "-"; const val = scope.rowData[col.prop] ?? "-";
return <OverflowTooltip val={val} />; return <OverflowTooltip val={val} />;
} },
}; };
/** /**
@@ -172,6 +204,11 @@ const updateRow = (id: string | number, rowData: object) => {
} }
}; };
// 获取当前已加载的数据
const getSelectedRows = () => {
return innerData.value.filter(row => selectedKeys.value.has(row.id));
};
/** /**
* 删除某一行数据 * 删除某一行数据
* @param id 唯一标识 * @param id 唯一标识
@@ -205,24 +242,32 @@ const addRow = (rowData: object) => {
// --- 适配 Columns 配置 --- // --- 适配 Columns 配置 ---
const adaptedColumns = computed(() => { const adaptedColumns = computed(() => {
// 1. 获取容器当前实际宽度
const containerWidth = tableSize.value.width; const containerWidth = tableSize.value.width;
if (containerWidth <= 0) return []; if (containerWidth <= 0) return [];
// 2. 找出没有设置宽度的列 // 从配置中提取宽度,如果没有传入则默认为 50
const hasSelection = props.selection;
const selectionWidth = hasSelection ? (props.selectionConfig?.width || 50) : 0;
// 1. 找出用户配置中没有设置宽度的列
const autoColumns = props.columns.filter((col: any) => !col.width); const autoColumns = props.columns.filter((col: any) => !col.width);
// 3. 计算已经固定了宽度的列总和
const fixedWidthTotal = props.columns // 2. 计算所有显式设置了宽度的用户列总和
const userFixedWidthTotal = props.columns
.filter((col: any) => col.width) .filter((col: any) => col.width)
.reduce((prev, curr: any) => prev + Number(curr.width), 0); .reduce((prev, curr: any) => prev + Number(curr.width), 0);
// 4. 计算剩余可用宽度
const remainingWidth = Math.max(containerWidth - fixedWidthTotal, 0); // 3. 计算剩余可用宽度 (如果开启了 selection需要额外扣除 selection 的宽度)
// 5. 计算每个自适应列应该分配到的平均宽度 const occupiedWidth = userFixedWidthTotal + selectionWidth;
const perAutoWidth = const remainingWidth = Math.max(containerWidth - occupiedWidth, 0);
autoColumns.length > 0
// 4. 计算自适应列宽度
const perAutoWidth = autoColumns.length > 0
? Math.floor(remainingWidth / autoColumns.length) ? Math.floor(remainingWidth / autoColumns.length)
: 0; : 0;
return props.columns.map((col: any) => {
// 5. 映射用户列
const userColumns = props.columns.map((col: any) => {
const isAuto = !col.width; const isAuto = !col.width;
const finalWidth = isAuto ? Math.max(perAutoWidth, 100) : Number(col.width); const finalWidth = isAuto ? Math.max(perAutoWidth, 100) : Number(col.width);
return { return {
@@ -235,62 +280,57 @@ const adaptedColumns = computed(() => {
fixed: col.fixed, fixed: col.fixed,
align: col.align || "left", align: col.align || "left",
cellRenderer: (scope: any) => { cellRenderer: (scope: any) => {
const isOp = col.prop === "actions" || col.valueType === "actions"; // ... 此处保留你原有的 cellRenderer 逻辑
const cellClass = isOp ? "operation-column-cell" : "";
// 2. 如果是操作列,包裹一层带 class 的 div
if (isOp && col.actions) {
return (
<div class={cellClass}>
<div class="v2-operation-btns">
{col.actions.map((btn) => {
// --- 权限校验开始 ---
if (btn.permission) {
const hasAuth = checkPermission(btn.permission);
if (!hasAuth) return null; // 没权限,直接不渲染
}
// 2. 现有的 show 逻辑判断(业务逻辑显隐)
const isShow =
typeof btn.show === "function"
? btn.show(scope.rowData)
: true;
const isDisabled =
typeof btn.disabled === "function"
? btn.disabled(scope.rowData)
: false;
if (!isShow) return null;
const {
onClick,
label,
permission,
show,
disabled,
...otherProps
} = btn;
return (
<ElButton
{...otherProps}
disabled={isDisabled}
onClick={() => btn.onClick(scope.rowData)}
>
{btn.label}
</ElButton>
);
})}
</div>
</div>
);
}
if (typeof col.render === "function") return col.render(scope); if (typeof col.render === "function") return col.render(scope);
if (col.valueType && RenderFactory[col.valueType]) { if (col.valueType && RenderFactory[col.valueType]) {
return RenderFactory[col.valueType](scope, col); return RenderFactory[col.valueType](scope, col);
} }
return ( return <span class="v2-cell-text">{scope.rowData[col.prop] ?? "-"}</span>;
<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;
}); });
// --- 请求逻辑 --- // --- 请求逻辑 ---
@@ -313,6 +353,13 @@ const fetchTableData = async (isReset = false) => {
const res = await props.requestApi(params); const res = await props.requestApi(params);
const records = res?.records || []; 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]; innerData.value = isReset ? records : [...innerData.value, ...records];
total.value = res?.total || 0; total.value = res?.total || 0;
pageNo.value++; pageNo.value++;
@@ -360,6 +407,11 @@ defineExpose({
removeRow, removeRow,
addRow, addRow,
updateSize, updateSize,
getSelection: () => getSelectedRows(),
clearSelection: () => {
isAllSelectedMode.value = false;
selectedKeys.value.clear();
},
getCurrentParams: () => ({ getCurrentParams: () => ({
pageNo: pageNo.value - 1, // 因为 fetch 完后 pageNo 会自增,所以要减 1 才是当前页 pageNo: pageNo.value - 1, // 因为 fetch 完后 pageNo 会自增,所以要减 1 才是当前页
pageSize: props.pageSize, pageSize: props.pageSize,

View File

@@ -1,6 +1,8 @@
<template> <template>
<div class="stage-breadcrumbs" :class="styleClass"> <div class="stage-breadcrumbs" :class="styleClass">
<div class="mj-panel-title">{{ title }}</div> <slot name="title">
<div class="mj-panel-title">{{ title }}</div>
</slot>
<div class="stage-breadcrumbs-content"> <div class="stage-breadcrumbs-content">
<slot name="content"></slot> <slot name="content"></slot>
</div> </div>
@@ -12,8 +14,8 @@
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: "StageBreadcrumbs" }); defineOptions({ name: "StageBreadcrumbs" });
const { title,styleClass } = defineProps<{ const { title,styleClass,showTitle=true } = defineProps<{
title: string; title?: string;
styleClass?: string; styleClass?: string;
}>(); }>();
</script> </script>

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

@@ -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 };
}

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

@@ -1,13 +1,603 @@
<template> <template>
<div class=""> <div class="customer-manage-container">
客户管理 <!-- 顶部搜索 -->
</div> <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> </template>
<script setup lang="ts"> <script setup lang="ts">
import {reactive,ref,onMounted} from "vue" 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" });
defineOptions({}) 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> </script>
<style lang="scss" scoped> <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;
}
</style> .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>

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')
}
},
})