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

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