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 : [
[$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()
}
}

18
components.d.ts vendored
View File

@@ -13,7 +13,9 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
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']
Comment: typeof import('./src/components/comment/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']
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']
@@ -80,6 +83,7 @@ declare module 'vue' {
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']
@@ -89,6 +93,9 @@ declare module 'vue' {
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']
}
@@ -101,7 +108,9 @@ declare module 'vue' {
// For TSX support
declare global {
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 Comment: typeof import('./src/components/comment/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 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']
@@ -168,6 +178,7 @@ declare global {
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']
@@ -177,6 +188,9 @@ declare global {
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

@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"test": "vitest",
"preview": "vite preview"
},
"dependencies": {
@@ -13,8 +14,11 @@
"@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"

661
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,14 @@
<template>
<div class="mj-card-container mj-grid-container">
<div
class="mj-card-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>
<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="card" :index="index"></slot>
<slot name="content" :item="item"></slot>
</div>
<div v-if="$slots.actions" class="mj-card-actions">
<slot name="actions" :item="card"></slot>
<slot name="actions" :item="item"></slot>
</div>
</div>
</div>
@@ -24,28 +17,21 @@
<script setup lang="ts">
defineOptions({ name: "CardItem" });
// item list
interface Props {
list: any[];
item: any;
standardTopStyle?: string | Record<string, any>;
}
const props = withDefaults(defineProps<Props>(), {
list: () => [],
standardTopStyle: "",
});
const props = defineProps<Props>();
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>
<style lang="scss" scoped>
.mj-grid-container {
//
.mj-card-item {
--radius: 12px;
--primary-color: #409eff;

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>
<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="onDownload" 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>
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;
@@ -89,21 +119,22 @@ const onDownload = () => {
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

@@ -52,11 +52,9 @@
:bgColor="'var(--mj-avatar-bg)'"
:avatarTextColor="'var(--mj-text-color)'"
/>
<el-tooltip :content="item.name">
<div class="select-item-name mj-ellipsis-one-line">
{{ item.name }}
</div>
</el-tooltip>
<div class="select-item-name">
<AutoTooltip :content="item.name"/>
</div>
</div>
</el-option>
<el-option
@@ -129,7 +127,7 @@ 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,

View File

@@ -7,6 +7,7 @@
'--avatar-text-size': fontSize,
'--avatar-text-color': avatarTextColor,
}"
v-bind="$attrs"
class="mj-name-avatar"
>
<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">
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 dayjs from "dayjs";
import { DictManage } from "@/dict";
@@ -54,6 +61,16 @@ const props = defineProps({
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"
})
}
});
// 内部数据状态
@@ -64,17 +81,31 @@ 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: "" }
val: { type: String, default: "" },
},
setup(props) {
const isOverflow = ref(false);
@@ -83,7 +114,8 @@ const OverflowTooltip = defineComponent({
const checkOverflow = () => {
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}
</div>
)
),
}}
</ElTooltip>
);
}
},
});
const RenderFactory = {
// 状态点/标签
@@ -156,7 +188,7 @@ const RenderFactory = {
ellipsis: (scope: any, col: any) => {
const val = scope.rowData[col.prop] ?? "-";
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 唯一标识
@@ -205,24 +242,32 @@ const addRow = (rowData: object) => {
// --- 适配 Columns 配置 ---
const adaptedColumns = computed(() => {
// 1. 获取容器当前实际宽度
const containerWidth = tableSize.value.width;
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);
// 3. 计算已经固定了宽度的列总和
const fixedWidthTotal = props.columns
// 2. 计算所有显式设置了宽度的用户列总和
const userFixedWidthTotal = props.columns
.filter((col: any) => col.width)
.reduce((prev, curr: any) => prev + Number(curr.width), 0);
// 4. 计算剩余可用宽度
const remainingWidth = Math.max(containerWidth - fixedWidthTotal, 0);
// 5. 计算每个自适应列应该分配到的平均宽度
const perAutoWidth =
autoColumns.length > 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;
return props.columns.map((col: any) => {
// 5. 映射用户列
const userColumns = props.columns.map((col: any) => {
const isAuto = !col.width;
const finalWidth = isAuto ? Math.max(perAutoWidth, 100) : Number(col.width);
return {
@@ -235,62 +280,57 @@ const adaptedColumns = computed(() => {
fixed: col.fixed,
align: col.align || "left",
cellRenderer: (scope: any) => {
const isOp = col.prop === "actions" || col.valueType === "actions";
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>
);
}
// ... 此处保留你原有的 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>
);
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;
});
// --- 请求逻辑 ---
@@ -313,6 +353,13 @@ const fetchTableData = async (isReset = false) => {
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++;
@@ -360,6 +407,11 @@ defineExpose({
removeRow,
addRow,
updateSize,
getSelection: () => getSelectedRows(),
clearSelection: () => {
isAllSelectedMode.value = false;
selectedKeys.value.clear();
},
getCurrentParams: () => ({
pageNo: pageNo.value - 1, // 因为 fetch 完后 pageNo 会自增,所以要减 1 才是当前页
pageSize: props.pageSize,

View File

@@ -1,6 +1,8 @@
<template>
<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">
<slot name="content"></slot>
</div>
@@ -12,8 +14,8 @@
<script setup lang="ts">
defineOptions({ name: "StageBreadcrumbs" });
const { title,styleClass } = defineProps<{
title: string;
const { title,styleClass,showTitle=true } = defineProps<{
title?: string;
styleClass?: string;
}>();
</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>
<div class="">
客户管理
</div>
<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 {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>
<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>

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