Merge remote-tracking branch 'yudao/dev' into dev

This commit is contained in:
jason
2025-11-20 13:32:53 +08:00
727 changed files with 20322 additions and 21931 deletions

View File

@@ -42,10 +42,11 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'InputNumber',
componentProps: {
placeholder: '请输入温度参数',
class: 'w-full',
precision: 2,
min: 0,
max: 2,
controlsPosition: 'right',
class: '!w-full',
},
rules: 'required',
},
@@ -55,9 +56,10 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'InputNumber',
componentProps: {
placeholder: '请输入回复数 Token 数',
class: 'w-full',
min: 0,
max: 8192,
controlsPosition: 'right',
class: '!w-full',
},
rules: 'required',
},
@@ -67,9 +69,10 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'InputNumber',
componentProps: {
placeholder: '请输入上下文数量',
class: 'w-full',
min: 0,
max: 20,
controlsPosition: 'right',
class: '!w-full',
},
rules: 'required',
},

View File

@@ -547,7 +547,10 @@ onMounted(async () => {
<template>
<Page auto-content-height>
<ElContainer class="absolute left-0 top-0 m-4 h-full w-full flex-1">
<ElContainer
direction="horizontal"
class="absolute left-0 top-0 m-4 h-full w-full flex-1"
>
<!-- 左侧对话列表 -->
<ConversationList
class="!bg-card"
@@ -560,7 +563,7 @@ onMounted(async () => {
/>
<!-- 右侧详情部分 -->
<ElContainer class="bg-card mx-4">
<ElContainer direction="vertical" class="bg-card mx-4 flex-1">
<ElHeader
class="!bg-card border-border flex !h-12 items-center justify-between border-b !px-4"
>
@@ -571,30 +574,30 @@ onMounted(async () => {
</span>
</div>
<div class="flex w-72 justify-end" v-if="activeConversation">
<div class="flex w-72 justify-end gap-2" v-if="activeConversation">
<ElButton
type="primary"
plain
class="mr-2 px-2"
class="!px-2"
size="small"
@click="openChatConversationUpdateForm"
>
<span v-html="activeConversation?.modelName"></span>
<IconifyIcon icon="lucide:settings" class="ml-2 size-4" />
<IconifyIcon icon="lucide:settings" class="!ml-2 size-4" />
</ElButton>
<ElButton
size="small"
class="mr-2 px-2"
class="!ml-0 !px-2"
@click="handlerMessageClear"
>
<IconifyIcon icon="lucide:trash-2" color="#787878" />
</ElButton>
<ElButton size="small" class="mr-2 px-2">
<ElButton size="small" class="!ml-0 !px-2">
<IconifyIcon icon="lucide:download" color="#787878" />
</ElButton>
<ElButton
size="small"
class="mr-2 px-2"
class="!ml-0 !px-2"
@click="handleGoTopMessage"
>
<IconifyIcon icon="lucide:arrow-up" color="#787878" />
@@ -629,7 +632,7 @@ onMounted(async () => {
</div>
</ElMain>
<ElFooter class="!bg-card flex flex-col !p-0">
<ElFooter height="auto" class="!bg-card flex flex-col !p-0">
<form
class="border-border mx-4 mb-8 mt-2 flex flex-col rounded-xl border p-2"
>
@@ -673,7 +676,8 @@ onMounted(async () => {
{{ conversationInProgress ? '进行中' : '发送' }}
</ElButton>
<ElButton
type="danger"
type="primary"
:danger="true"
@click="stopStream()"
v-if="conversationInProgress === true"
>

View File

@@ -166,232 +166,277 @@ async function getConversationGroupByCreateTime(
return groupMap;
}
/** 新建对话 */
async function handleConversationCreate() {
// 1. 创建对话
const conversationId = await createChatConversationMy({
roleId: undefined,
} as unknown as AiChatConversationApi.ChatConversation);
// 2. 刷新列表
async function createConversation() {
// 1. 新建对话
const conversationId = await createChatConversationMy(
{} as unknown as AiChatConversationApi.ChatConversation,
);
// 2. 获取对话内容
await getChatConversationList();
// 3. 选中对话
await handleConversationClick(conversationId);
// 4. 回调
emits('onConversationCreate');
}
// 3. 回调
emits('onConversationCreate', conversationId);
/** 修改对话的标题 */
async function updateConversationTitle(
conversation: AiChatConversationApi.ChatConversation,
) {
// 1. 二次确认
await prompt({
async beforeClose(scope) {
if (scope.isConfirm) {
if (scope.value) {
try {
// 2. 发起修改
await updateChatConversationMy({
id: conversation.id,
title: scope.value,
} as AiChatConversationApi.ChatConversation);
ElMessage.success('重命名成功');
// 3. 刷新列表
await getChatConversationList();
// 4. 过滤当前切换的
const filterConversationList = conversationList.value.filter(
(item) => {
return item.id === conversation.id;
},
);
if (
filterConversationList.length > 0 &&
filterConversationList[0] && // tip避免切换对话
activeConversationId.value === filterConversationList[0].id!
) {
emits('onConversationClick', filterConversationList[0]);
}
} catch {
return false;
}
} else {
ElMessage.error('请输入标题');
return false;
}
}
},
component: () => {
return h(ElInput, {
placeholder: '请输入标题',
clearable: true,
modelValue: conversation.title,
});
},
content: '请输入标题',
title: '修改标题',
modelPropName: 'modelValue',
});
}
/** 删除聊天对话 */
async function deleteChatConversation(
conversation: AiChatConversationApi.ChatConversation,
) {
// 删除的二次确认
await confirm(`是否确认删除对话 - ${conversation.title}?`);
// 发起删除
await deleteChatConversationMy(conversation.id);
ElMessage.success('对话已删除');
// 刷新列表
await getChatConversationList();
// 回调
emits('onConversationDelete', conversation);
}
/** 清空未置顶的对话 */
async function handleConversationClear() {
await confirm({
title: '清空未置顶的对话',
content: h('div', {}, [
h('p', '确认清空未置顶的对话吗?'),
h('p', '清空后,未置顶的对话将被删除,无法恢复!'),
]),
});
// 清空
async function handleClearConversation() {
await confirm('确认后对话会全部清空,置顶的对话除外。');
await deleteChatConversationMyByUnpinned();
// 刷新列表
ElMessage.success($t('ui.actionMessage.operationSuccess'));
// 清空对话、对话内容
activeConversationId.value = null;
// 获取对话列表
await getChatConversationList();
// 回调
// 回调 方法
emits('onConversationClear');
}
/** 删除对话 */
async function handleConversationDelete(id: number) {
await confirm({
title: '删除对话',
content: h('div', {}, [
h('p', '确认删除该对话吗?'),
h('p', '删除后,该对话将被删除,无法恢复!'),
]),
});
// 删除
await deleteChatConversationMy(id);
// 刷新列表
await getChatConversationList();
// 回调
emits('onConversationDelete', id);
}
/** 置顶对话 */
async function handleConversationPin(conversation: any) {
// 更新
await updateChatConversationMy({
id: conversation.id,
pinned: !conversation.pinned,
} as AiChatConversationApi.ChatConversation);
// 刷新列表
/** 对话置顶 */
async function handleTop(conversation: AiChatConversationApi.ChatConversation) {
// 更新对话置顶
conversation.pinned = !conversation.pinned;
await updateChatConversationMy(conversation);
// 刷新对话
await getChatConversationList();
}
/** 编辑对话 */
async function handleConversationEdit(conversation: any) {
const title = await prompt({
title: '编辑对话',
content: '请输入对话标题',
defaultValue: conversation.title,
});
// 更新
await updateChatConversationMy({
id: conversation.id,
title,
} as AiChatConversationApi.ChatConversation);
// 刷新列表
await getChatConversationList();
// 提示
ElMessage.success($t('ui.actionMessage.operationSuccess'));
}
// ============ 角色仓库 ============
/** 打开角色仓库 */
async function handleRoleRepositoryOpen() {
/** 角色仓库抽屉 */
const handleRoleRepository = async () => {
drawerApi.open();
}
/** 监听 activeId 变化 */
watch(
() => props.activeId,
(newValue) => {
activeConversationId.value = newValue;
},
);
};
/** 监听选中的对话 */
const { activeId } = toRefs(props);
watch(activeId, async (newValue) => {
activeConversationId.value = newValue;
});
defineExpose({ createConversation });
/** 初始化 */
onMounted(async () => {
// 获取对话列表
// 获取 对话列表
await getChatConversationList();
// 设置选中的对话
if (activeId.value) {
activeConversationId.value = activeId.value;
// 默认选中
if (props.activeId) {
activeConversationId.value = props.activeId;
} else {
// 首次默认选中第一个
if (conversationList.value.length > 0 && conversationList.value[0]) {
activeConversationId.value = conversationList.value[0].id;
// 回调 onConversationClick
emits('onConversationClick', conversationList.value[0]);
}
}
});
defineExpose({ getChatConversationList });
</script>
<template>
<ElAside
class="bg-card relative flex h-full flex-col overflow-hidden border-r border-gray-200"
width="280px"
class="relative flex h-full flex-col justify-between overflow-hidden p-4"
>
<Drawer />
<!-- 头部 -->
<div class="flex flex-col p-4">
<div class="mb-4 flex flex-row items-center justify-between">
<div class="text-lg font-bold">对话</div>
<div class="flex flex-row">
<ElButton
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
text
@click="handleConversationCreate"
>
<IconifyIcon icon="lucide:plus" />
</ElButton>
<ElButton
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
text
@click="handleConversationClear"
>
<IconifyIcon icon="lucide:trash" />
</ElButton>
<ElButton
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
text
@click="handleRoleRepositoryOpen"
>
<IconifyIcon icon="lucide:user" />
</ElButton>
</div>
</div>
<!-- 左顶部对话 -->
<div class="flex h-full flex-col">
<ElButton class="h-9 w-full" type="primary" @click="createConversation">
<IconifyIcon icon="lucide:plus" class="mr-1" />
新建对话
</ElButton>
<ElInput
v-model="searchName"
placeholder="搜索对话"
@keyup.enter="searchConversation"
size="large"
class="search-input mt-4"
placeholder="搜索历史记录"
@keyup="searchConversation"
>
<template #suffix>
<template #prefix>
<IconifyIcon icon="lucide:search" />
</template>
</ElInput>
</div>
<!-- 对话列表 -->
<div class="flex-1 overflow-y-auto px-4">
<div v-if="loading" class="flex h-full items-center justify-center">
<div class="text-sm text-gray-400">加载中...</div>
</div>
<div v-else-if="Object.keys(conversationMap).length === 0">
<ElEmpty description="暂无对话" />
</div>
<div v-else>
<!-- 左中间对话列表 -->
<div class="mt-2 flex-1 overflow-auto">
<!-- 情况一加载中 -->
<ElEmpty v-if="loading" description="." v-loading="loading" />
<!-- 情况二按照 group 分组 -->
<div
v-for="(conversations, groupName) in conversationMap"
:key="groupName"
v-for="conversationKey in Object.keys(conversationMap)"
:key="conversationKey"
>
<div
v-if="conversations.length > 0"
class="mb-2 mt-4 text-xs text-gray-400"
v-if="conversationMap[conversationKey].length > 0"
class="classify-title pt-2"
>
{{ groupName }}
<p class="mx-1">
{{ conversationKey }}
</p>
</div>
<div
v-for="conversation in conversations"
v-for="conversation in conversationMap[conversationKey]"
:key="conversation.id"
class="group relative mb-2 cursor-pointer rounded-lg p-2 transition-all hover:bg-gray-100"
:class="{
'bg-gray-100': activeConversationId === conversation.id,
}"
@click="handleConversationClick(conversation.id)"
@mouseenter="hoverConversationId = conversation.id"
@mouseleave="hoverConversationId = null"
@mouseover="hoverConversationId = conversation.id"
@mouseout="hoverConversationId = null"
class="mt-1"
>
<div class="flex items-center">
<ElAvatar
v-if="conversation.roleAvatar"
:src="conversation.roleAvatar"
:size="28"
/>
<SvgGptIcon v-else class="size-7" />
<div class="ml-2 flex-1 overflow-hidden">
<div class="truncate text-sm font-medium">
<div
class="mb-2 flex cursor-pointer flex-row items-center justify-between rounded-lg px-2 leading-10 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
:class="[
conversation.id === activeConversationId
? 'bg-primary/10 dark:bg-primary/20'
: '',
]"
>
<div class="flex items-center">
<ElAvatar
v-if="conversation.roleAvatar"
:src="conversation.roleAvatar"
:size="28"
/>
<SvgGptIcon v-else class="size-6" />
<span
class="max-w-32 overflow-hidden text-ellipsis whitespace-nowrap p-2 text-sm font-normal"
>
{{ conversation.title }}
</div>
</span>
</div>
<div
v-if="hoverConversationId === conversation.id"
class="flex flex-row"
v-show="hoverConversationId === conversation.id"
class="relative right-0.5 flex items-center text-gray-400"
>
<!-- TODO @AI三个按钮之间间隙太大了 -->
<ElButton
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
text
@click.stop="handleConversationPin(conversation)"
class="mr-0 px-1"
link
@click.stop="handleTop(conversation)"
>
<IconifyIcon
:icon="
conversation.pinned ? 'lucide:pin-off' : 'lucide:pin'
"
v-if="!conversation.pinned"
icon="lucide:arrow-up-to-line"
/>
<IconifyIcon
v-if="conversation.pinned"
icon="lucide:arrow-down-from-line"
/>
</ElButton>
<ElButton
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
text
@click.stop="handleConversationEdit(conversation)"
class="mr-0 px-1"
link
@click.stop="updateConversationTitle(conversation)"
>
<IconifyIcon icon="lucide:edit" />
</ElButton>
<ElButton
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
text
@click.stop="handleConversationDelete(conversation.id)"
class="mr-0 px-1"
link
@click.stop="deleteChatConversation(conversation)"
>
<IconifyIcon icon="lucide:trash" />
<IconifyIcon icon="lucide:trash-2" />
</ElButton>
</div>
</div>
</div>
</div>
</div>
<!-- 底部占位 -->
<div class="h-12 w-full"></div>
</div>
<!-- 左底部工具栏 -->
<div
class="bg-card absolute bottom-1 left-0 right-0 mb-4 flex items-center justify-between px-5 leading-9 text-gray-400 shadow-sm"
>
<div
class="flex cursor-pointer items-center text-gray-400"
@click="handleRoleRepository"
>
<IconifyIcon icon="lucide:user" />
<span class="ml-1">角色仓库</span>
</div>
<div
class="flex cursor-pointer items-center text-gray-400"
@click="handleClearConversation"
>
<IconifyIcon icon="lucide:trash" />
<span class="ml-1">清空未置顶对话</span>
</div>
</div>
</ElAside>
</template>

View File

@@ -18,11 +18,9 @@ async function handlerPromptClick(prompt: any) {
</script>
<template>
<div class="relative flex h-full w-full flex-row justify-center">
<!-- center-container -->
<div class="flex flex-col justify-center">
<!-- title -->
<div class="text-center text-3xl font-bold">芋道 AI</div>
<!-- role-list -->
<div class="mt-5 flex w-96 flex-wrap items-center justify-center">
<div

View File

@@ -37,7 +37,7 @@ const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']);
const { copy } = useClipboard(); // 初始化 copy 到粘贴板
const userStore = useUserStore();
// 判断"消息列表"滚动的位置(用于判断是否需要滚动到消息最下方)
// 判断消息列表滚动的位置(用于判断是否需要滚动到消息最下方)
const messageContainer: any = ref(null);
const isScrolling = ref(false); // 用于判断用户是否在滚动
@@ -88,6 +88,7 @@ async function copyContent(content: string) {
await copy(content);
ElMessage.success('复制成功!');
}
/** 删除 */
async function handleDelete(id: number) {
// 删除 message
@@ -153,7 +154,7 @@ onMounted(async () => {
</div>
<div class="mt-2 flex flex-row">
<ElButton
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
class="!ml-1 flex items-center bg-transparent !px-1.5 hover:bg-gray-100"
text
@click="copyContent(item.content)"
>
@@ -161,7 +162,7 @@ onMounted(async () => {
</ElButton>
<ElButton
v-if="item.id > 0"
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
class="!ml-1 flex items-center bg-transparent !px-1.5 hover:bg-gray-100"
text
@click="handleDelete(item.id)"
>
@@ -196,28 +197,28 @@ onMounted(async () => {
</div>
<div class="mt-2 flex flex-row-reverse">
<ElButton
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
class="!ml-1 flex items-center bg-transparent !px-1.5 hover:bg-gray-100"
text
@click="copyContent(item.content)"
>
<IconifyIcon icon="lucide:copy" />
</ElButton>
<ElButton
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
class="!ml-1 flex items-center bg-transparent !px-1.5 hover:bg-gray-100"
text
@click="handleDelete(item.id)"
>
<IconifyIcon icon="lucide:trash" />
</ElButton>
<ElButton
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
class="!ml-1 flex items-center bg-transparent !px-1.5 hover:bg-gray-100"
text
@click="handleRefresh(item)"
>
<IconifyIcon icon="lucide:refresh-cw" />
</ElButton>
<ElButton
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
class="!ml-1 flex items-center bg-transparent !px-1.5 hover:bg-gray-100"
text
@click="handleEdit(item)"
>

View File

@@ -71,12 +71,15 @@ function toggleExpanded() {
.scrollbar-thin::-webkit-scrollbar {
width: 4px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
@apply rounded-sm bg-gray-400/40;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400/60;
}

View File

@@ -24,7 +24,7 @@ async function handleCategoryClick(category: string) {
</script>
<template>
<div class="flex flex-wrap items-center">
<div class="mx-0 flex flex-wrap items-center">
<div
class="mr-2 flex flex-row"
v-for="category in categoryList"

View File

@@ -63,59 +63,59 @@ async function handleTabsScroll() {
<template>
<div
class="relative flex h-full flex-wrap content-start items-start overflow-auto px-6 pb-36"
class="relative flex h-full flex-wrap content-start items-start overflow-auto pb-36"
ref="tabsRef"
@scroll="handleTabsScroll"
>
<div class="mb-5 mr-5 inline-block" v-for="role in roleList" :key="role.id">
<div class="mb-3 mr-3 inline-block" v-for="role in roleList" :key="role.id">
<ElCard
class="relative rounded-lg"
body-style="position: relative; display: flex; flex-direction: row; justify-content: flex-start; width: 240px; max-width: 240px; padding: 15px 15px 10px;"
body-style="position: relative; display: flex; flex-direction: column; justify-content: flex-start; width: 240px; max-width: 240px; padding: 15px;"
>
<!-- 更多操作 -->
<div v-if="showMore" class="absolute right-2 top-0">
<ElDropdown>
<ElButton link>
<IconifyIcon icon="lucide:ellipsis-vertical" />
<!-- 头部头像名称 -->
<div class="flex items-center justify-between">
<div class="flex min-w-0 flex-1 items-center">
<ElAvatar
:src="role.avatar"
class="h-8 w-8 flex-shrink-0 overflow-hidden"
/>
<div class="ml-2 truncate text-base font-medium">
{{ role.name }}
</div>
</div>
</div>
<!-- 描述信息 -->
<div
class="mt-2 line-clamp-2 h-10 overflow-hidden text-sm text-gray-600"
>
{{ role.description }}
</div>
<!-- 底部操作按钮 -->
<div class="flex items-center justify-end gap-2">
<ElDropdown v-if="showMore">
<ElButton size="small">
<IconifyIcon icon="lucide:ellipsis" />
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem @click="handleMoreClick('edit', role)">
<div class="flex items-center">
<IconifyIcon icon="lucide:edit" color="#787878" />
<span class="text-primary">编辑</span>
</div>
</ElDropdownItem>
<ElDropdownItem @click="handleMoreClick('delete', role)">
<div class="flex items-center">
<IconifyIcon icon="lucide:trash" color="red" />
<span class="text-red-500">删除</span>
<span class="ml-2 text-red-500">删除</span>
</div>
</ElDropdownItem>
<ElDropdownItem @click="handleMoreClick('edit', role)">
<div class="flex items-center">
<IconifyIcon icon="lucide:edit" color="#787878" />
<span class="text-primary ml-2">编辑</span>
</div>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
<!-- 角色信息 -->
<div>
<ElAvatar :src="role.avatar" class="h-10 w-10 overflow-hidden" />
</div>
<div class="ml-2 w-4/5">
<div class="h-20">
<div class="max-w-32 text-lg font-bold">
{{ role.name }}
</div>
<div class="mt-2 text-sm">
{{ role.description }}
</div>
</div>
<div class="mt-1 flex flex-row-reverse">
<ElButton type="primary" size="small" @click="handleUseClick(role)">
使用
</ElButton>
</div>
<ElButton type="primary" size="small" @click="handleUseClick(role)">
使用
</ElButton>
</div>
</ElCard>
</div>

View File

@@ -119,6 +119,7 @@ async function handlerCategoryClick(category: string) {
async function handlerAddRole() {
formModalApi.setData({ formType: 'my-create' }).open();
}
/** 编辑角色 */
async function handlerCardEdit(role: any) {
formModalApi.setData({ formType: 'my-update', id: role.id }).open();
@@ -187,7 +188,7 @@ onMounted(async () => {
<FormModal @success="handlerAddRoleSuccess" />
<ElMain class="relative m-0 flex-1 overflow-hidden p-0">
<div class="z-100 absolute right-0 top--1 mr-5 mt-5">
<div class="z-100 absolute right-5 top-5 flex items-center">
<!-- 搜索输入框 -->
<ElInput
v-model="search"
@@ -196,7 +197,11 @@ onMounted(async () => {
@keyup.enter="getActiveTabsRole"
>
<template #suffix>
<IconifyIcon icon="lucide:search" />
<IconifyIcon
icon="lucide:search"
class="cursor-pointer"
@click="getActiveTabsRole"
/>
</template>
</ElInput>
<ElButton
@@ -209,11 +214,10 @@ onMounted(async () => {
添加角色
</ElButton>
</div>
<!-- 标签页内容 -->
<ElTabs
v-model="activeTab"
class="relative h-full p-4"
class="relative h-full pb-4 pr-4"
@tab-click="handleTabsClick"
>
<ElTabPane
@@ -229,10 +233,8 @@ onMounted(async () => {
@on-edit="handlerCardEdit"
@on-use="handlerCardUse"
@on-page="handlerCardPage('my')"
class="mt-5"
/>
</ElTabPane>
<ElTabPane
name="public-role"
class="flex h-full flex-col overflow-y-auto"

View File

@@ -69,7 +69,7 @@ async function handleGenerateImage() {
width: width.value, // 图片宽度
height: height.value, // 图片高度
options: {},
} as unknown as AiImageApi.ImageDrawReq;
} as unknown as AiImageApi.ImageDrawReqVO;
await drawImage(form);
} finally {
// 回调

View File

@@ -116,7 +116,7 @@ async function handleGenerateImage() {
options: {
style: style.value, // 图像生成的风格
},
} as AiImageApi.ImageDrawReq;
} as AiImageApi.ImageDrawReqVO;
// 发送请求
await drawImage(form);
} finally {

View File

@@ -144,7 +144,7 @@ async function handleImageMidjourneyButtonClick(
const data = {
id: imageDetail.id,
customId: button.customId,
} as AiImageApi.ImageMidjourneyAction;
} as AiImageApi.ImageMidjourneyActionVO;
// 2. 发送 action
await midjourneyAction(data);
// 3. 刷新列表

View File

@@ -103,7 +103,7 @@ async function handleGenerateImage() {
height: imageSize.height,
version: selectVersion.value,
referImageUrl: referImageUrl.value,
} as AiImageApi.ImageMidjourneyImagineReq;
} as AiImageApi.ImageMidjourneyImagineReqVO;
await midjourneyImagine(req);
} finally {
// 回调

View File

@@ -103,7 +103,7 @@ async function handleGenerateImage() {
clipGuidancePreset: clipGuidancePreset.value, // 文本提示相匹配的图像 CLIP
stylePreset: stylePreset.value, // 风格
},
} as unknown as AiImageApi.ImageDrawReq;
} as unknown as AiImageApi.ImageDrawReqVO;
await drawImage(form);
} finally {
// 回调

View File

@@ -189,11 +189,17 @@ export function useGridFormSchema(): VbenFormSchema[] {
fieldName: 'name',
label: '角色名称',
component: 'Input',
componentProps: {
placeholder: '请输入角色名称',
},
},
{
fieldName: 'category',
label: '角色类别',
component: 'Input',
componentProps: {
placeholder: '请输入角色类别',
},
},
{
fieldName: 'publicStatus',

View File

@@ -105,10 +105,10 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'InputNumber',
componentProps: {
placeholder: '请输入温度参数',
controlsPosition: 'right',
class: '!w-full',
min: 0,
max: 2,
controlsPosition: 'right',
class: '!w-full',
},
dependencies: {
triggerFields: ['type'],

View File

@@ -14,7 +14,7 @@ import { useGridColumns, useGridFormSchema } from './data';
defineOptions({ name: 'BpmCopyTask' });
/** 任务详情 */
function handleDetail(row: BpmProcessInstanceApi.Copy) {
function handleDetail(row: BpmProcessInstanceApi.ProcessInstanceCopyRespVO) {
const query = {
id: row.processInstanceId,
...(row.activityId && { activityId: row.activityId }),
@@ -52,7 +52,7 @@ const [Grid] = useVbenVxeGrid({
refresh: true,
search: true,
},
} as VxeTableGridOptions<BpmProcessInstanceApi.Copy>,
} as VxeTableGridOptions<BpmProcessInstanceApi.ProcessInstanceCopyRespVO>,
});
</script>

View File

@@ -49,7 +49,7 @@ const [Grid] = useVbenVxeGrid({
refresh: true,
search: true,
},
} as VxeTableGridOptions<BpmTaskApi.TaskManager>,
} as VxeTableGridOptions<BpmTaskApi.Task>,
});
</script>

View File

@@ -0,0 +1,102 @@
import type { Ref } from 'vue';
export interface LeftSideItem {
name: string;
menu: string;
count: Ref<number>;
}
/** 跟进状态 */
export const FOLLOWUP_STATUS = [
{ label: '待跟进', value: false },
{ label: '已跟进', value: true },
];
/** 归属范围 */
export const SCENE_TYPES = [
{ label: '我负责的', value: 1 },
{ label: '我参与的', value: 2 },
{ label: '下属负责的', value: 3 },
];
/** 联系状态 */
export const CONTACT_STATUS = [
{ label: '今日需联系', value: 1 },
{ label: '已逾期', value: 2 },
{ label: '已联系', value: 3 },
];
/** 审批状态 */
export const AUDIT_STATUS = [
{ label: '待审批', value: 10 },
{ label: '审核通过', value: 20 },
{ label: '审核不通过', value: 30 },
];
/** 回款提醒类型 */
export const RECEIVABLE_REMIND_TYPE = [
{ label: '待回款', value: 1 },
{ label: '已逾期', value: 2 },
{ label: '已回款', value: 3 },
];
/** 合同过期状态 */
export const CONTRACT_EXPIRY_TYPE = [
{ label: '即将过期', value: 1 },
{ label: '已过期', value: 2 },
];
/** 左侧菜单 */
export const useLeftSides = (
customerTodayContactCount: Ref<number>,
clueFollowCount: Ref<number>,
customerFollowCount: Ref<number>,
customerPutPoolRemindCount: Ref<number>,
contractAuditCount: Ref<number>,
contractRemindCount: Ref<number>,
receivableAuditCount: Ref<number>,
receivablePlanRemindCount: Ref<number>,
): LeftSideItem[] => {
return [
{
name: '今日需联系客户',
menu: 'customerTodayContact',
count: customerTodayContactCount,
},
{
name: '分配给我的线索',
menu: 'clueFollow',
count: clueFollowCount,
},
{
name: '分配给我的客户',
menu: 'customerFollow',
count: customerFollowCount,
},
{
name: '待进入公海的客户',
menu: 'customerPutPoolRemind',
count: customerPutPoolRemindCount,
},
{
name: '待审核合同',
menu: 'contractAudit',
count: contractAuditCount,
},
{
name: '待审核回款',
menu: 'receivableAudit',
count: receivableAuditCount,
},
{
name: '待回款提醒',
menu: 'receivablePlanRemind',
count: receivablePlanRemindCount,
},
{
name: '即将到期的合同',
menu: 'contractRemind',
count: contractRemindCount,
},
];
};

View File

@@ -0,0 +1,115 @@
<script lang="ts" setup>
import { computed, onActivated, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { ElBadge, ElCard } from 'element-plus';
import { getFollowClueCount } from '#/api/crm/clue';
import {
getAuditContractCount,
getRemindContractCount,
} from '#/api/crm/contract';
import {
getFollowCustomerCount,
getPutPoolRemindCustomerCount,
getTodayContactCustomerCount,
} from '#/api/crm/customer';
import { getAuditReceivableCount } from '#/api/crm/receivable';
import { getReceivablePlanRemindCount } from '#/api/crm/receivable/plan';
import { useLeftSides } from './data';
import ClueFollowList from './modules/clue-follow-list.vue';
import ContractAuditList from './modules/contract-audit-list.vue';
import ContractRemindList from './modules/contract-remind-list.vue';
import CustomerFollowList from './modules/customer-follow-list.vue';
import CustomerPutPoolRemindList from './modules/customer-put-pool-remind-list.vue';
import CustomerTodayContactList from './modules/customer-today-contact-list.vue';
import ReceivableAuditList from './modules/receivable-audit-list.vue';
import ReceivablePlanRemindList from './modules/receivable-plan-remind-list.vue';
const leftMenu = ref('customerTodayContact');
const clueFollowCount = ref(0);
const customerFollowCount = ref(0);
const customerPutPoolRemindCount = ref(0);
const customerTodayContactCount = ref(0);
const contractAuditCount = ref(0);
const contractRemindCount = ref(0);
const receivableAuditCount = ref(0);
const receivablePlanRemindCount = ref(0);
const leftSides = useLeftSides(
customerTodayContactCount,
clueFollowCount,
customerFollowCount,
customerPutPoolRemindCount,
contractAuditCount,
contractRemindCount,
receivableAuditCount,
receivablePlanRemindCount,
);
const currentComponent = computed(() => {
const components = {
customerTodayContact: CustomerTodayContactList,
clueFollow: ClueFollowList,
contractAudit: ContractAuditList,
receivableAudit: ReceivableAuditList,
contractRemind: ContractRemindList,
customerFollow: CustomerFollowList,
customerPutPoolRemind: CustomerPutPoolRemindList,
receivablePlanRemind: ReceivablePlanRemindList,
} as const;
return components[leftMenu.value as keyof typeof components];
});
/** 侧边点击 */
function sideClick(item: { menu: string }) {
leftMenu.value = item.menu;
}
/** 获取数量 */
async function getCount() {
customerTodayContactCount.value = await getTodayContactCustomerCount();
customerPutPoolRemindCount.value = await getPutPoolRemindCustomerCount();
customerFollowCount.value = await getFollowCustomerCount();
clueFollowCount.value = await getFollowClueCount();
contractAuditCount.value = await getAuditContractCount();
contractRemindCount.value = await getRemindContractCount();
receivableAuditCount.value = await getAuditReceivableCount();
receivablePlanRemindCount.value = await getReceivablePlanRemindCount();
}
/** 激活时 */
onActivated(() => {
getCount();
});
/** 初始化 */
onMounted(() => {
getCount();
});
</script>
<template>
<Page auto-content-height>
<div class="flex h-full w-full">
<ElCard class="w-1/5">
<div v-for="item in leftSides" :key="item.menu">
<div
class="flex cursor-pointer items-center justify-between border-b px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700"
@click="sideClick(item)"
>
<div>{{ item.name }}</div>
<ElBadge
v-if="item.count.value > 0"
:value="item.count.value"
:type="item.menu === leftMenu ? 'primary' : 'danger'"
/>
</div>
</div>
</ElCard>
<component class="ml-4 w-4/5" :is="currentComponent" />
</div>
</Page>
</template>

View File

@@ -0,0 +1,79 @@
<!-- 分配给我的线索 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmClueApi } from '#/api/crm/clue';
import { useRouter } from 'vue-router';
import { ElButton } from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCluePage } from '#/api/crm/clue';
import { useGridColumns } from '#/views/crm/clue/data';
import { FOLLOWUP_STATUS } from '../data';
const { push } = useRouter();
/** 打开线索详情 */
function handleDetail(row: CrmClueApi.Clue) {
push({ name: 'CrmClueDetail', params: { id: row.id } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: [
{
fieldName: 'followUpStatus',
label: '状态',
component: 'RadioGroup',
componentProps: {
allowClear: true,
options: FOLLOWUP_STATUS,
},
defaultValue: false,
},
],
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCluePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
transformStatus: false,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmClueApi.Clue>,
});
</script>
<template>
<Grid>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #actions="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
查看详情
</ElButton>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,124 @@
<!-- 待审核合同 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmContractApi } from '#/api/crm/contract';
import { useRouter } from 'vue-router';
import { ElButton } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getContractPage } from '#/api/crm/contract';
import { useGridColumns } from '#/views/crm/contract/data';
import { AUDIT_STATUS } from '../data';
const { push } = useRouter();
/** 查看审批 */
function handleProcessDetail(row: CrmContractApi.Contract) {
push({
name: 'BpmProcessInstanceDetail',
query: { id: row.processInstanceId },
});
}
/** 打开合同详情 */
function handleContractDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContractDetail', params: { id: row.id } });
}
/** 打开客户详情 */
function handleCustomerDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
/** 打开联系人详情 */
function handleContactDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContactDetail', params: { id: row.id } });
}
/** 打开商机详情 */
function handleBusinessDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmBusinessDetail', params: { id: row.id } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: [
{
fieldName: 'auditStatus',
label: '合同状态',
component: 'Select',
componentProps: {
allowClear: true,
options: AUDIT_STATUS,
},
defaultValue: AUDIT_STATUS[0]!.value,
},
],
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getContractPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
sceneType: 1, // 我负责的
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmContractApi.Contract>,
});
</script>
<template>
<Grid>
<template #name="{ row }">
<ElButton type="primary" link @click="handleContractDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #customerName="{ row }">
<ElButton type="primary" link @click="handleCustomerDetail(row)">
{{ row.customerName }}
</ElButton>
</template>
<template #businessName="{ row }">
<ElButton type="primary" link @click="handleBusinessDetail(row)">
{{ row.businessName }}
</ElButton>
</template>
<template #contactName="{ row }">
<ElButton type="primary" link @click="handleContactDetail(row)">
{{ row.contactName }}
</ElButton>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '查看审批',
type: 'primary',
link: true,
icon: ACTION_ICON.VIEW,
onClick: handleProcessDetail.bind(null, row),
},
]"
/>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,125 @@
<!-- 即将到期的合同 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmContractApi } from '#/api/crm/contract';
import { useRouter } from 'vue-router';
import { ElButton } from 'element-plus';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getContractPage } from '#/api/crm/contract';
import { useGridColumns } from '#/views/crm/contract/data';
import { CONTRACT_EXPIRY_TYPE } from '../data';
const { push } = useRouter();
/** 查看审批 */
function handleProcessDetail(row: CrmContractApi.Contract) {
push({
name: 'BpmProcessInstanceDetail',
query: { id: row.processInstanceId },
});
}
/** 打开合同详情 */
function handleContractDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContractDetail', params: { id: row.id } });
}
/** 打开客户详情 */
function handleCustomerDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
/** 打开联系人详情 */
function handleContactDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContactDetail', params: { id: row.id } });
}
/** 打开商机详情 */
function handleBusinessDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmBusinessDetail', params: { id: row.id } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: [
{
fieldName: 'expiryType',
label: '到期状态',
component: 'Select',
componentProps: {
allowClear: true,
options: CONTRACT_EXPIRY_TYPE,
},
defaultValue: CONTRACT_EXPIRY_TYPE[0]!.value,
},
],
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getContractPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
sceneType: 1, // 自己负责的
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmContractApi.Contract>,
});
</script>
<template>
<Grid>
<template #name="{ row }">
<ElButton type="primary" link @click="handleContractDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #customerName="{ row }">
<ElButton type="primary" link @click="handleCustomerDetail(row)">
{{ row.customerName }}
</ElButton>
</template>
<template #businessName="{ row }">
<ElButton type="primary" link @click="handleBusinessDetail(row)">
{{ row.businessName }}
</ElButton>
</template>
<template #signContactName="{ row }">
<ElButton type="primary" link @click="handleContactDetail(row)">
{{ row.signContactName }}
</ElButton>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '查看审批',
type: 'primary',
link: true,
auth: ['crm:contract:update'],
onClick: handleProcessDetail.bind(null, row),
},
]"
/>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,79 @@
<!-- 分配给我的客户 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerApi } from '#/api/crm/customer';
import { useRouter } from 'vue-router';
import { ElButton } from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCustomerPage } from '#/api/crm/customer';
import { useGridColumns } from '#/views/crm/customer/data';
import { FOLLOWUP_STATUS } from '../data';
const { push } = useRouter();
/** 打开客户详情 */
function handleDetail(row: CrmCustomerApi.Customer) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: [
{
fieldName: 'followUpStatus',
label: '状态',
component: 'RadioGroup',
componentProps: {
allowClear: true,
options: FOLLOWUP_STATUS,
},
defaultValue: false,
},
],
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCustomerPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
sceneType: 1,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmCustomerApi.Customer>,
});
</script>
<template>
<Grid>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #actions="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
查看详情
</ElButton>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,79 @@
<!-- 待进入公海的客户 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerApi } from '#/api/crm/customer';
import { useRouter } from 'vue-router';
import { ElButton } from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCustomerPage } from '#/api/crm/customer';
import { useGridColumns } from '#/views/crm/customer/data';
import { SCENE_TYPES } from '../data';
const { push } = useRouter();
/** 打开客户详情 */
function handleDetail(row: CrmCustomerApi.Customer) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: [
{
fieldName: 'sceneType',
label: '归属',
component: 'Select',
componentProps: {
allowClear: true,
options: SCENE_TYPES,
},
defaultValue: SCENE_TYPES[0]!.value,
},
],
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCustomerPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
pool: true, // 固定 公海参数为 true
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmCustomerApi.Customer>,
});
</script>
<template>
<Grid>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #actions="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
查看详情
</ElButton>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,89 @@
<!-- 今日需联系客户 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerApi } from '#/api/crm/customer';
import { useRouter } from 'vue-router';
import { ElButton } from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCustomerPage } from '#/api/crm/customer';
import { useGridColumns } from '#/views/crm/customer/data';
import { CONTACT_STATUS, SCENE_TYPES } from '../data';
const { push } = useRouter();
/** 打开客户详情 */
function handleDetail(row: CrmCustomerApi.Customer) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: [
{
fieldName: 'contactStatus',
label: '状态',
component: 'Select',
componentProps: {
allowClear: true,
options: CONTACT_STATUS,
},
defaultValue: CONTACT_STATUS[0]!.value,
},
{
fieldName: 'sceneType',
label: '归属',
component: 'Select',
componentProps: {
allowClear: true,
options: SCENE_TYPES,
},
defaultValue: SCENE_TYPES[0]!.value,
},
],
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCustomerPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
pool: null, // 是否公海数据
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmCustomerApi.Customer>,
});
</script>
<template>
<Grid>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #actions="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
查看详情
</ElButton>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,106 @@
<!-- 待审核回款 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmReceivableApi } from '#/api/crm/receivable';
import { useRouter } from 'vue-router';
import { ElButton } from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getReceivablePage } from '#/api/crm/receivable';
import { useGridColumns } from '#/views/crm/receivable/data';
import { AUDIT_STATUS } from '../data';
const { push } = useRouter();
/** 查看审批 */
function handleProcessDetail(row: CrmReceivableApi.Receivable) {
push({
name: 'BpmProcessInstanceDetail',
query: { id: row.processInstanceId },
});
}
/** 打开回款详情 */
function handleDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmReceivableDetail', params: { id: row.id } });
}
/** 打开客户详情 */
function handleCustomerDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 打开合同详情 */
function handleContractDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmContractDetail', params: { id: row.contractId } });
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: [
{
fieldName: 'auditStatus',
label: '合同状态',
component: 'Select',
componentProps: {
allowClear: true,
options: AUDIT_STATUS,
},
defaultValue: AUDIT_STATUS[0]!.value,
},
],
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getReceivablePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmReceivableApi.Receivable>,
});
</script>
<template>
<Grid>
<template #no="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.no }}
</ElButton>
</template>
<template #customerName="{ row }">
<ElButton type="primary" link @click="handleCustomerDetail(row)">
{{ row.customerName }}
</ElButton>
</template>
<template #contractNo="{ row }">
<ElButton type="primary" link @click="handleContractDetail(row)">
{{ row.contractNo }}
</ElButton>
</template>
<template #actions="{ row }">
<ElButton type="primary" link @click="handleProcessDetail(row)">
查看审批
</ElButton>
</template>
</Grid>
</template>

View File

@@ -0,0 +1,104 @@
<!-- 待回款提醒 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmReceivablePlanApi } from '#/api/crm/receivable/plan';
import { useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import { ElButton } from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getReceivablePlanPage } from '#/api/crm/receivable/plan';
import Form from '#/views/crm/receivable/modules/form.vue';
import { useGridColumns } from '#/views/crm/receivable/plan/data';
import { RECEIVABLE_REMIND_TYPE } from '../data';
const { push } = useRouter();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 打开回款详情 */
function handleDetail(row: CrmReceivablePlanApi.Plan) {
push({ name: 'CrmReceivableDetail', params: { id: row.id } });
}
/** 打开客户详情 */
function handleCustomerDetail(row: CrmReceivablePlanApi.Plan) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 创建回款 */
function handleCreateReceivable(row: CrmReceivablePlanApi.Plan) {
formModalApi.setData({ plan: row }).open();
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: [
{
fieldName: 'remindType',
label: '合同状态',
component: 'Select',
componentProps: {
allowClear: true,
options: RECEIVABLE_REMIND_TYPE,
},
defaultValue: RECEIVABLE_REMIND_TYPE[0]!.value,
},
],
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getReceivablePlanPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmReceivablePlanApi.Plan>,
});
</script>
<template>
<div>
<FormModal />
<Grid>
<template #customerName="{ row }">
<ElButton type="primary" link @click="handleCustomerDetail(row)">
{{ row.customerName }}
</ElButton>
</template>
<template #period="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.period }}
</ElButton>
</template>
<template #actions="{ row }">
<ElButton type="primary" link @click="handleCreateReceivable(row)">
创建回款
</ElButton>
</template>
</Grid>
</div>
</template>

View File

@@ -0,0 +1,52 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
/** 商机关联列表列定义 */
export function useBusinessDetailListColumns(): VxeTableGridOptions['columns'] {
return [
{
type: 'checkbox',
width: 50,
fixed: 'left',
},
{
field: 'name',
title: '商机名称',
fixed: 'left',
slots: { default: 'name' },
},
{
field: 'customerName',
title: '客户名称',
fixed: 'left',
slots: { default: 'customerName' },
},
{
field: 'totalPrice',
title: '商机金额(元)',
formatter: 'formatAmount2',
},
{
field: 'dealTime',
title: '预计成交日期',
formatter: 'formatDate',
},
{
field: 'ownerUserName',
title: '负责人',
},
{
field: 'ownerUserDeptName',
title: '所属部门',
},
{
field: 'statusTypeName',
title: '商机状态组',
fixed: 'right',
},
{
field: 'statusName',
title: '商机阶段',
fixed: 'right',
},
];
}

View File

@@ -0,0 +1,149 @@
<!-- 商机选择对话框用于联系人详情中关联已有商机 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmBusinessApi } from '#/api/crm/business';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import { ElButton, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getBusinessPageByCustomer } from '#/api/crm/business';
import { $t } from '#/locales';
import Form from '../modules/form.vue';
import { useBusinessDetailListColumns } from './data';
const props = defineProps<{
customerId?: number; // 关联联系人与商机时,需要传入 customerId 进行筛选
}>();
const emit = defineEmits(['success']);
const { push } = useRouter();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const checkedRows = ref<CrmBusinessApi.Business[]>([]);
function setCheckedRows({ records }: { records: CrmBusinessApi.Business[] }) {
checkedRows.value = records;
}
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建商机 */
function handleCreate() {
formModalApi.setData({ customerId: props.customerId }).open();
}
/** 查看商机详情 */
function handleDetail(row: CrmBusinessApi.Business) {
push({ name: 'CrmBusinessDetail', params: { id: row.id } });
}
/** 查看客户详情 */
function handleCustomerDetail(row: CrmBusinessApi.Business) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 商机关联弹窗 */
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
if (checkedRows.value.length === 0) {
ElMessage.error('请先选择商机后操作!');
return;
}
modalApi.lock();
// 提交表单
try {
const businessIds = checkedRows.value.map((item) => item.id);
// 关闭并提示
await modalApi.close();
emit('success', businessIds, checkedRows.value);
} finally {
modalApi.unlock();
}
},
});
/** 商机选择表格 */
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: [
{
fieldName: 'name',
label: '商机名称',
component: 'Input',
},
],
},
gridOptions: {
columns: useBusinessDetailListColumns(),
height: 600,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getBusinessPageByCustomer({
pageNo: page.currentPage,
pageSize: page.pageSize,
customerId: props.customerId,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmBusinessApi.Business>,
gridEvents: {
checkboxAll: setCheckedRows,
checkboxChange: setCheckedRows,
},
});
</script>
<template>
<Modal title="关联商机" class="w-2/5">
<FormModal @success="handleRefresh" />
<Grid>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['商机']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:business:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #customerName="{ row }">
<ElButton type="primary" link @click="handleCustomerDetail(row)">
{{ row.customerName }}
</ElButton>
</template>
</Grid>
</Modal>
</template>

View File

@@ -0,0 +1,215 @@
<!-- 商机列表用于客户联系人详情中展示其关联的商机列表 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmBusinessApi } from '#/api/crm/business';
import type { CrmContactApi } from '#/api/crm/contact';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { confirm, useVbenModal } from '@vben/common-ui';
import { ElButton, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getBusinessPageByContact,
getBusinessPageByCustomer,
} from '#/api/crm/business';
import {
createContactBusinessList,
deleteContactBusinessList,
} from '#/api/crm/contact';
import { BizTypeEnum } from '#/api/crm/permission';
import { $t } from '#/locales';
import Form from '../modules/form.vue';
import { useBusinessDetailListColumns } from './data';
import ListModal from './detail-list-modal.vue';
const props = defineProps<{
bizId: number; // 业务编号
bizType: number; // 业务类型
contactId?: number; // 特殊:联系人编号;在【联系人】详情中,可以传递联系人编号,默认新建的商机关联到该联系人
customerId?: number; // 关联联系人与商机时,需要传入 customerId 进行筛选
}>();
const { push } = useRouter();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [DetailListModal, detailListModalApi] = useVbenModal({
connectedComponent: ListModal,
destroyOnClose: true,
});
const checkedRows = ref<CrmBusinessApi.Business[]>([]);
function setCheckedRows({ records }: { records: CrmBusinessApi.Business[] }) {
checkedRows.value = records;
}
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建商机 */
function handleCreate() {
formModalApi
.setData({ customerId: props.customerId, contactId: props.contactId })
.open();
}
/** 关联商机 */
function handleCreateBusiness() {
detailListModalApi.setData({ customerId: props.customerId }).open();
}
/** 解除商机关联 */
async function handleDeleteContactBusinessList() {
if (checkedRows.value.length === 0) {
ElMessage.error('请先选择商机后操作!');
return;
}
return new Promise((resolve, reject) => {
confirm({
content: `确定要将${checkedRows.value.map((item) => item.name).join(',')}解除关联吗?`,
})
.then(async () => {
const res = await deleteContactBusinessList({
contactId: props.bizId,
businessIds: checkedRows.value.map((item) => item.id),
});
if (res) {
// 提示并返回成功
ElMessage.success($t('ui.actionMessage.operationSuccess'));
handleRefresh();
resolve(true);
} else {
reject(new Error($t('ui.actionMessage.operationFailed')));
}
})
.catch(() => {
reject(new Error('取消操作'));
});
});
}
/** 查看商机详情 */
function handleDetail(row: CrmBusinessApi.Business) {
push({ name: 'CrmBusinessDetail', params: { id: row.id } });
}
/** 查看客户详情 */
function handleCustomerDetail(row: CrmBusinessApi.Business) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 创建联系人关联的商机 */
async function handleCreateContactBusinessList(businessIds: number[]) {
const data = {
contactId: props.bizId,
businessIds,
} as CrmContactApi.ContactBusinessReqVO;
await createContactBusinessList(data);
handleRefresh();
}
/** 商机关联表格 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useBusinessDetailListColumns(),
height: 600,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
if (props.bizType === BizTypeEnum.CRM_CUSTOMER) {
return await getBusinessPageByCustomer({
pageNo: page.currentPage,
pageSize: page.pageSize,
customerId: props.customerId,
...formValues,
});
} else if (props.bizType === BizTypeEnum.CRM_CONTACT) {
return await getBusinessPageByContact({
pageNo: page.currentPage,
pageSize: page.pageSize,
contactId: props.contactId,
...formValues,
});
} else {
return [];
}
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmBusinessApi.Business>,
gridEvents: {
checkboxAll: setCheckedRows,
checkboxChange: setCheckedRows,
},
});
</script>
<template>
<div>
<FormModal @success="handleRefresh" />
<DetailListModal
:customer-id="customerId"
@success="handleCreateContactBusinessList"
/>
<Grid>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['商机']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:business:create'],
onClick: handleCreate,
},
{
label: '关联',
icon: ACTION_ICON.ADD,
type: 'default',
auth: ['crm:contact:create-business'],
ifShow: () => !!contactId,
onClick: handleCreateBusiness,
},
{
label: '解除关联',
icon: ACTION_ICON.ADD,
type: 'default',
auth: ['crm:contact:create-business'],
ifShow: () => !!contactId,
onClick: handleDeleteContactBusinessList,
},
]"
/>
</template>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #customerName="{ row }">
<ElButton type="primary" link @click="handleCustomerDetail(row)">
{{ row.customerName }}
</ElButton>
</template>
</Grid>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as BusinessDetailsList } from './detail-list.vue';

View File

@@ -0,0 +1,281 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { useUserStore } from '@vben/stores';
import { erpPriceMultiply } from '@vben/utils';
import { z } from '#/adapter/form';
import { getBusinessStatusTypeSimpleList } from '#/api/crm/business/status';
import { getCustomerSimpleList } from '#/api/crm/customer';
import { getSimpleUserList } from '#/api/system/user';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
const userStore = useUserStore();
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '商机名称',
component: 'Input',
rules: 'required',
componentProps: {
placeholder: '请输入商机名称',
allowClear: true,
},
},
{
fieldName: 'ownerUserId',
label: '负责人',
component: 'ApiSelect',
dependencies: {
triggerFields: ['id'],
disabled: (values) => values.id,
},
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择负责人',
allowClear: true,
},
defaultValue: userStore.userInfo?.id,
rules: 'required',
},
{
fieldName: 'customerId',
label: '客户名称',
component: 'ApiSelect',
componentProps: {
api: getCustomerSimpleList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择客户',
allowClear: true,
},
dependencies: {
triggerFields: ['id'],
disabled: (values) => values.customerDefault,
},
rules: 'required',
},
{
fieldName: 'contactId',
label: '合同名称',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'statusTypeId',
label: '商机状态组',
component: 'ApiSelect',
componentProps: {
api: getBusinessStatusTypeSimpleList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择商机状态组',
allowClear: true,
},
dependencies: {
triggerFields: ['id'],
disabled: (values) => values.id,
},
rules: 'required',
},
{
fieldName: 'dealTime',
label: '预计成交日期',
component: 'DatePicker',
componentProps: {
showTime: false,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
placeholder: '请选择预计成交日期',
class: '!w-full',
},
},
{
fieldName: 'product',
label: '产品清单',
component: 'Input',
formItemClass: 'col-span-3',
componentProps: {
placeholder: '请输入产品清单',
allowClear: true,
},
},
{
fieldName: 'totalProductPrice',
label: '产品总金额',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
disabled: true,
placeholder: '请输入产品总金额',
controlsPosition: 'right',
class: '!w-full',
},
rules: z.number().min(0).optional().default(0),
},
{
fieldName: 'discountPercent',
label: '整单折扣(%',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
placeholder: '请输入整单折扣',
controlsPosition: 'right',
class: '!w-full',
},
rules: z.number().min(0).max(100).optional().default(0),
},
{
fieldName: 'totalPrice',
label: '折扣后金额',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
disabled: true,
placeholder: '请输入折扣后金额',
controlsPosition: 'right',
class: '!w-full',
},
dependencies: {
triggerFields: ['totalProductPrice', 'discountPercent'],
trigger(values, form) {
const discountPrice =
erpPriceMultiply(
values.totalProductPrice,
values.discountPercent / 100,
) ?? 0;
form.setFieldValue(
'totalPrice',
values.totalProductPrice - discountPrice,
);
},
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '商机名称',
component: 'Input',
componentProps: {
placeholder: '请输入商机名称',
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '商机名称',
fixed: 'left',
width: 160,
slots: { default: 'name' },
},
{
field: 'customerName',
title: '客户名称',
fixed: 'left',
width: 120,
slots: { default: 'customerName' },
},
{
field: 'totalPrice',
title: '商机金额(元)',
width: 140,
formatter: 'formatAmount2',
},
{
field: 'dealTime',
title: '预计成交日期',
formatter: 'formatDate',
width: 180,
},
{
field: 'remark',
title: '备注',
width: 200,
},
{
field: 'contactNextTime',
title: '下次联系时间',
formatter: 'formatDateTime',
width: 180,
},
{
field: 'ownerUserName',
title: '负责人',
width: 100,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
width: 100,
},
{
field: 'contactLastTime',
title: '最后跟进时间',
formatter: 'formatDateTime',
width: 180,
},
{
field: 'updateTime',
title: '更新时间',
formatter: 'formatDateTime',
width: 180,
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
width: 180,
},
{
field: 'creatorName',
title: '创建人',
width: 100,
},
{
field: 'statusTypeName',
title: '商机状态组',
fixed: 'right',
width: 140,
},
{
field: 'statusName',
title: '商机阶段',
fixed: 'right',
width: 120,
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,141 @@
import type { Ref } from 'vue';
import type { VbenFormSchema } from '#/adapter/form';
import type { CrmBusinessApi } from '#/api/crm/business';
import type { DescriptionItemSchema } from '#/components/description';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import {
DEFAULT_STATUSES,
getBusinessStatusSimpleList,
} from '#/api/crm/business/status';
/** 详情页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'customerName',
label: '客户名称',
},
{
field: 'totalPrice',
label: '商机金额(元)',
render: (val) => erpPriceInputFormatter(val),
},
{
field: 'statusTypeName',
label: '商机组',
},
{
field: 'ownerUserName',
label: '负责人',
},
{
field: 'createTime',
label: '创建时间',
render: (val) => formatDateTime(val) as string,
},
];
}
/** 详情页的基础字段 */
export function useDetailBaseSchema(): DescriptionItemSchema[] {
return [
{
field: 'name',
label: '商机名称',
},
{
field: 'customerName',
label: '客户名称',
},
{
field: 'totalPrice',
label: '商机金额(元)',
render: (val) => erpPriceInputFormatter(val),
},
{
field: 'dealTime',
label: '预计成交日期',
render: (val) => formatDateTime(val) as string,
},
{
field: 'contactNextTime',
label: '下次联系时间',
render: (val) => formatDateTime(val) as string,
},
{
field: 'statusTypeName',
label: '商机状态组',
},
{
field: 'statusName',
label: '商机阶段',
},
{
field: 'remark',
label: '备注',
},
];
}
/** 商机状态更新表单 */
export function useStatusFormSchema(
formData: Ref<CrmBusinessApi.Business | undefined>,
): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'statusId',
label: '商机状态',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'endStatus',
label: '商机状态',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'status',
label: '商机阶段',
component: 'Select',
dependencies: {
triggerFields: [''],
async componentProps() {
const statusList = await getBusinessStatusSimpleList(
formData.value?.statusTypeId ?? 0,
);
const statusOptions = statusList.map((item) => ({
label: `${item.name}(赢单率:${item.percent}%)`,
value: item.id,
}));
const options = DEFAULT_STATUSES.map((item) => ({
label: `${item.name}(赢单率:${item.percent}%)`,
value: item.endStatus,
}));
statusOptions.push(...options);
return {
options: statusOptions,
};
},
},
rules: 'required',
},
];
}

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import type { CrmBusinessApi } from '#/api/crm/business';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { ElCard, ElTabPane, ElTabs } from 'element-plus';
import { getBusiness } from '#/api/crm/business';
import { getOperateLogPage } from '#/api/crm/operateLog';
import { BizTypeEnum } from '#/api/crm/permission';
import { useDescription } from '#/components/description';
import { OperateLog } from '#/components/operate-log';
import { ACTION_ICON, TableAction } from '#/components/table-action';
import { $t } from '#/locales';
import { ContactDetailsList } from '#/views/crm/contact/components';
import { ContractDetailsList } from '#/views/crm/contract/components';
import { FollowUp } from '#/views/crm/followup';
import { PermissionList, TransferForm } from '#/views/crm/permission';
import { ProductDetailsList } from '#/views/crm/product/components';
import Form from '../modules/form.vue';
import { useDetailSchema } from './data';
import BusinessDetailsInfo from './modules/info.vue';
import UpStatusForm from './modules/status-form.vue';
const route = useRoute();
const router = useRouter();
const tabs = useTabs();
const loading = ref(false); // 加载中
const businessId = ref(0); // 商机编号
const business = ref<CrmBusinessApi.Business>({} as CrmBusinessApi.Business); // 商机详情
const logList = ref<SystemOperateLogApi.OperateLog[]>([]);
const permissionListRef = ref<InstanceType<typeof PermissionList>>(); // 团队成员列表 Ref
const activeTabName = ref('1'); // 选中 Tab 名
const [Descriptions] = useDescription({
border: false,
column: 4,
schema: useDetailSchema(),
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [TransferModal, transferModalApi] = useVbenModal({
connectedComponent: TransferForm,
destroyOnClose: true,
});
const [UpStatusModal, upStatusModalApi] = useVbenModal({
connectedComponent: UpStatusForm,
destroyOnClose: true,
});
/** 加载详情 */
async function getBusinessDetail() {
loading.value = true;
try {
business.value = await getBusiness(businessId.value);
// 操作日志
const res = await getOperateLogPage({
bizType: BizTypeEnum.CRM_BUSINESS,
bizId: businessId.value,
});
logList.value = res.list;
} finally {
loading.value = false;
}
}
/** 返回列表页 */
function handleBack() {
tabs.closeCurrentTab();
router.push({ name: 'CrmBusiness' });
}
/** 编辑商机 */
function handleEdit() {
formModalApi.setData({ id: businessId.value }).open();
}
/** 转移商机 */
function handleTransfer() {
transferModalApi.setData({ bizType: BizTypeEnum.CRM_BUSINESS }).open();
}
/** 更新商机状态操作 */
async function handleUpdateStatus() {
upStatusModalApi.setData(business.value).open();
}
/** 加载数据 */
onMounted(() => {
businessId.value = Number(route.params.id);
getBusinessDetail();
});
</script>
<template>
<Page auto-content-height :title="business?.name" :loading="loading">
<FormModal @success="getBusinessDetail" />
<TransferModal @success="getBusinessDetail" />
<UpStatusModal @success="getBusinessDetail" />
<template #extra>
<TableAction
:actions="[
{
label: '返回',
type: 'default',
icon: 'lucide:arrow-left',
onClick: handleBack,
},
{
label: $t('ui.actionTitle.edit'),
type: 'primary',
icon: ACTION_ICON.EDIT,
auth: ['crm:business:update'],
ifShow: permissionListRef?.validateWrite,
onClick: handleEdit,
},
{
label: '变更商机状态',
type: 'primary',
ifShow: permissionListRef?.validateWrite,
onClick: handleUpdateStatus,
},
{
label: '转移',
type: 'primary',
ifShow: permissionListRef?.validateOwnerUser,
onClick: handleTransfer,
},
]"
/>
</template>
<ElCard class="min-h-[10%]">
<Descriptions :data="business" />
</ElCard>
<ElCard class="mt-4 min-h-[60%]">
<ElTabs v-model:model-value="activeTabName">
<ElTabPane label="跟进记录" name="1">
<FollowUp :biz-id="businessId" :biz-type="BizTypeEnum.CRM_BUSINESS" />
</ElTabPane>
<ElTabPane label="详细资料" name="2">
<BusinessDetailsInfo :business="business" />
</ElTabPane>
<ElTabPane label="联系人" name="3">
<ContactDetailsList
:biz-id="businessId"
:biz-type="BizTypeEnum.CRM_BUSINESS"
:business-id="businessId"
:customer-id="business.customerId"
/>
</ElTabPane>
<ElTabPane label="产品" name="4">
<ProductDetailsList
:biz-id="businessId"
:biz-type="BizTypeEnum.CRM_BUSINESS"
:business="business"
/>
</ElTabPane>
<ElTabPane label="合同" name="5">
<ContractDetailsList
:biz-id="businessId"
:biz-type="BizTypeEnum.CRM_BUSINESS"
/>
</ElTabPane>
<ElTabPane label="操作日志" name="6">
<OperateLog :log-list="logList" />
</ElTabPane>
<ElTabPane label="团队成员" name="7">
<PermissionList
ref="permissionListRef"
:biz-id="businessId"
:biz-type="BizTypeEnum.CRM_BUSINESS"
:show-action="true"
@quit-team="handleBack"
/>
</ElTabPane>
</ElTabs>
</ElCard>
</Page>
</template>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import type { CrmBusinessApi } from '#/api/crm/business';
import { ElDivider } from 'element-plus';
import { useDescription } from '#/components/description';
import { useFollowUpDetailSchema } from '#/views/crm/followup/data';
import { useDetailBaseSchema } from '../data';
defineProps<{
business: CrmBusinessApi.Business; // 商机信息
}>();
const [BaseDescription] = useDescription({
title: '基本信息',
border: false,
column: 4,
schema: useDetailBaseSchema(),
});
const [SystemDescription] = useDescription({
title: '系统信息',
border: false,
column: 3,
schema: useFollowUpDetailSchema(),
});
</script>
<template>
<div>
<BaseDescription :data="business" />
<ElDivider />
<SystemDescription :data="business" />
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { CrmBusinessApi } from '#/api/crm/business';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { updateBusinessStatus } from '#/api/crm/business';
import { $t } from '#/locales';
import { useStatusFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmBusinessApi.Business>();
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useStatusFormSchema(formData),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as CrmBusinessApi.Business;
try {
if (!data.status) {
return;
}
await updateBusinessStatus({
id: data.id,
statusId: data.status > 0 ? data.status : undefined,
endStatus: data.status < 0 ? -data.status : undefined,
});
// 关闭并提示
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
// 加载数据
const data = modalApi.getData<CrmBusinessApi.Business>();
if (!data || !data.id) {
return;
}
data.status = data.endStatus === null ? data.statusId : -data.endStatus;
formData.value = data;
modalApi.lock();
try {
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal title="变更商机状态" class="w-2/5">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,208 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmBusinessApi } from '#/api/crm/business';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import {
ElButton,
ElLoading,
ElMessage,
ElTabPane,
ElTabs,
} from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteBusiness,
exportBusiness,
getBusinessPage,
} from '#/api/crm/business';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const { push } = useRouter();
const sceneType = ref('1');
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 处理场景类型的切换 */
function handleChangeSceneType(key: number | string) {
sceneType.value = key.toString();
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const formValues = await gridApi.formApi.getValues();
const data = await exportBusiness({
sceneType: sceneType.value,
...formValues,
});
downloadFileFromBlobPart({ fileName: '商机.xls', source: data });
}
/** 创建商机 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑商机 */
function handleEdit(row: CrmBusinessApi.Business) {
formModalApi.setData(row).open();
}
/** 删除商机 */
async function handleDelete(row: CrmBusinessApi.Business) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
});
try {
await deleteBusiness(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 查看商机详情 */
function handleDetail(row: CrmBusinessApi.Business) {
push({ name: 'CrmBusinessDetail', params: { id: row.id } });
}
/** 查看客户详情 */
function handleCustomerDetail(row: CrmBusinessApi.Business) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getBusinessPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
sceneType: sceneType.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmBusinessApi.Business>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【商机】商机管理、商机状态"
url="https://doc.iocoder.cn/crm/business/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal @success="handleRefresh" />
<Grid>
<template #toolbar-actions>
<ElTabs
class="w-full"
@tab-change="handleChangeSceneType"
v-model:model-value="sceneType"
>
<ElTabPane label="我负责的" name="1" />
<ElTabPane label="我参与的" name="2" />
<ElTabPane label="下属负责的" name="3" />
</ElTabs>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['商机']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:business:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['crm:business:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #customerName="{ row }">
<ElButton type="primary" link @click="handleCustomerDetail(row)">
{{ row.customerName }}
</ElButton>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['crm:business:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:business:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,121 @@
<script lang="ts" setup>
import type { CrmBusinessApi } from '#/api/crm/business';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { erpPriceMultiply } from '@vben/utils';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createBusiness,
getBusiness,
updateBusiness,
} from '#/api/crm/business';
import { BizTypeEnum } from '#/api/crm/permission';
import { $t } from '#/locales';
import { ProductEditTable } from '#/views/crm/product/components';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmBusinessApi.Business>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['商机'])
: $t('ui.actionTitle.create', ['商机']);
});
function handleUpdateProducts(products: any) {
formData.value = modalApi.getData<CrmBusinessApi.Business>();
formData.value!.products = products;
if (formData.value) {
const totalProductPrice =
formData.value.products?.reduce(
(prev, curr) => prev + curr.totalPrice,
0,
) ?? 0;
const discountPercent = formData.value.discountPercent;
const discountPrice =
discountPercent === null
? 0
: erpPriceMultiply(totalProductPrice, discountPercent / 100);
const totalPrice = totalProductPrice - (discountPrice ?? 0);
formData.value!.totalProductPrice = totalProductPrice;
formData.value!.totalPrice = totalPrice;
formApi.setValues(formData.value!);
}
}
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 120,
},
wrapperClass: 'grid-cols-3',
layout: 'vertical',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as CrmBusinessApi.Business;
data.products = formData.value?.products;
try {
await (formData.value?.id ? updateBusiness(data) : createBusiness(data));
// 关闭并提示
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<CrmBusinessApi.Business>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = data.id ? await getBusiness(data.id) : data;
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/2">
<Form class="mx-4">
<template #product="slotProps">
<ProductEditTable
v-bind="slotProps"
class="w-full"
:products="formData?.products ?? []"
:biz-type="BizTypeEnum.CRM_BUSINESS"
@update:products="handleUpdateProducts"
/>
</template>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,112 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { handleTree } from '@vben/utils';
import { getDeptList } from '#/api/system/dept';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '状态组名',
component: 'Input',
rules: 'required',
componentProps: {
placeholder: '请输入状态组名',
},
},
{
fieldName: 'deptIds',
label: '应用部门',
component: 'ApiTreeSelect',
componentProps: {
api: async () => {
const data = await getDeptList();
return handleTree(data);
},
multiple: true,
fieldNames: { label: 'name', value: 'id', children: 'children' },
placeholder: '请选择应用部门',
treeDefaultExpandAll: true,
},
help: '不选择部门时,默认全公司生效',
},
{
fieldName: 'statuses',
label: '阶段设置',
component: 'Input',
rules: 'required',
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '状态组名',
},
{
field: 'deptNames',
title: '应用部门',
formatter: ({ cellValue }) =>
cellValue?.length > 0 ? cellValue.join(' ') : '全公司',
},
{
field: 'creator',
title: '创建人',
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 商机状态阶段列表列配置 */
export function useFormColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'defaultStatus',
title: '阶段',
minWidth: 100,
slots: { default: 'defaultStatus' },
},
{
field: 'name',
title: '阶段名称',
minWidth: 100,
slots: { default: 'name' },
},
{
field: 'percent',
title: '赢单率(%',
minWidth: 100,
slots: { default: 'percent' },
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,136 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmBusinessStatusApi } from '#/api/crm/business/status';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteBusinessStatus,
getBusinessStatusPage,
} from '#/api/crm/business/status';
import { $t } from '#/locales';
import { useGridColumns } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建商机状态 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑商机状态 */
function handleEdit(row: CrmBusinessStatusApi.BusinessStatus) {
formModalApi.setData(row).open();
}
/** 删除商机状态 */
async function handleDelete(row: CrmBusinessStatusApi.BusinessStatus) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
});
try {
await deleteBusinessStatus(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getBusinessStatusPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmBusinessStatusApi.BusinessStatus>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【商机】商机管理、商机状态"
url="https://doc.iocoder.cn/crm/business/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal @success="handleRefresh" />
<Grid table-title="商机状态列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['商机状态']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:post:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['crm:business-status:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:business-status:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,208 @@
<script lang="ts" setup>
import type { CrmBusinessStatusApi } from '#/api/crm/business/status';
import { computed, nextTick, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElInput, ElInputNumber, ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
createBusinessStatus,
DEFAULT_STATUSES,
getBusinessStatus,
updateBusinessStatus,
} from '#/api/crm/business/status';
import { $t } from '#/locales';
import { useFormColumns, useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmBusinessStatusApi.BusinessStatus>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['商机状态'])
: $t('ui.actionTitle.create', ['商机状态']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data =
(await formApi.getValues()) as CrmBusinessStatusApi.BusinessStatus;
try {
if (formData.value?.statuses && formData.value.statuses.length > 0) {
data.statuses = formData.value.statuses;
data.statuses.splice(-3, 3);
}
await (formData.value?.id
? updateBusinessStatus(data)
: createBusinessStatus(data));
// 关闭并提示
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
// 加载数据
const data = modalApi.getData<CrmBusinessStatusApi.BusinessStatus>();
modalApi.lock();
try {
if (!data || !data.id) {
formData.value = {
id: undefined,
name: '',
deptIds: [],
statuses: [],
};
await handleAddStatus();
} else {
formData.value = await getBusinessStatus(data.id);
if (
!formData.value?.statuses?.length ||
formData.value?.statuses?.length === 0
) {
await handleAddStatus();
}
}
// 设置到 values
await formApi.setValues(formData.value as any);
await gridApi.grid.reloadData(
(formData.value!.statuses =
formData.value?.statuses?.concat(DEFAULT_STATUSES)) as any,
);
} finally {
modalApi.unlock();
}
},
});
/** 添加状态 */
async function handleAddStatus() {
formData.value!.statuses!.splice(-3, 0, {
name: '',
percent: undefined,
} as any);
await nextTick();
await gridApi.grid.reloadData(formData.value!.statuses as any);
}
/** 删除状态 */
async function deleteStatusArea(row: any, rowIndex: number) {
await gridApi.grid.remove(row);
formData.value!.statuses!.splice(rowIndex, 1);
await gridApi.grid.reloadData(formData.value!.statuses as any);
}
/** 表格配置 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
editConfig: {
trigger: 'click',
mode: 'cell',
},
columns: useFormColumns(),
data: formData.value?.statuses?.concat(DEFAULT_STATUSES),
border: true,
showOverflow: true,
autoResize: true,
keepSource: true,
rowConfig: {
keyField: 'row_id',
isHover: true,
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/2">
<Form class="mx-4">
<template #statuses>
<Grid class="w-full">
<template #defaultStatus="{ row, rowIndex }">
<span>
{{ row.defaultStatus ? '结束' : `阶段${rowIndex + 1}` }}
</span>
</template>
<template #name="{ row }">
<ElInput
v-if="!row.endStatus"
v-model="row.name"
placeholder="请输入状态名"
/>
<span v-else>{{ row.name }}</span>
</template>
<template #percent="{ row }">
<ElInputNumber
v-if="!row.endStatus"
v-model="row.percent"
:min="0"
:max="100"
:precision="2"
placeholder="请输入赢单率"
controls-position="right"
class="!w-full"
/>
<span v-else>{{ row.percent }}</span>
</template>
<template #actions="{ row, rowIndex }">
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create'),
type: 'primary',
link: true,
ifShow: () => !row.endStatus,
onClick: handleAddStatus,
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
ifShow: () => !row.endStatus,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: deleteStatusArea.bind(null, row, rowIndex),
},
},
]"
/>
</template>
</Grid>
</template>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,327 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { useUserStore } from '@vben/stores';
import { getAreaTree } from '#/api/system/area';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
const userStore = useUserStore();
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '线索名称',
component: 'Input',
rules: 'required',
componentProps: {
placeholder: '请输入线索名称',
},
},
{
fieldName: 'source',
label: '客户来源',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_SOURCE, 'number'),
placeholder: '请选择客户来源',
},
rules: 'required',
},
{
fieldName: 'mobile',
label: '手机',
component: 'Input',
componentProps: {
placeholder: '请输入手机号',
},
},
{
fieldName: 'ownerUserId',
label: '负责人',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
clearable: true,
placeholder: '请选择负责人',
},
defaultValue: userStore.userInfo?.id,
rules: 'required',
dependencies: {
triggerFields: ['id'],
disabled: (values) => values.id,
},
},
{
fieldName: 'telephone',
label: '电话',
component: 'Input',
componentProps: {
placeholder: '请输入电话',
},
},
{
fieldName: 'email',
label: '邮箱',
component: 'Input',
componentProps: {
placeholder: '请输入邮箱',
},
},
{
fieldName: 'wechat',
label: '微信',
component: 'Input',
componentProps: {
placeholder: '请输入微信',
},
},
{
fieldName: 'qq',
label: 'QQ',
component: 'Input',
componentProps: {
placeholder: '请输入 QQ',
},
},
{
fieldName: 'industryId',
label: '客户行业',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_INDUSTRY, 'number'),
placeholder: '请选择客户行业',
},
},
{
fieldName: 'level',
label: '客户级别',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_LEVEL, 'number'),
placeholder: '请选择客户级别',
},
},
{
fieldName: 'areaId',
label: '地址',
component: 'ApiTreeSelect',
componentProps: {
api: getAreaTree,
fieldNames: { label: 'name', value: 'id', children: 'children' },
placeholder: '请选择地址',
},
},
{
fieldName: 'detailAddress',
label: '详细地址',
component: 'Input',
componentProps: {
placeholder: '请输入详细地址',
},
},
{
fieldName: 'contactNextTime',
label: '下次联系时间',
component: 'DatePicker',
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
placeholder: '请选择下次联系时间',
class: '!w-full',
},
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '线索名称',
component: 'Input',
componentProps: {
placeholder: '请输入线索名称',
clearable: true,
},
},
{
fieldName: 'transformStatus',
label: '转化状态',
component: 'RadioGroup',
componentProps: {
options: [
{ label: '未转化', value: false },
{ label: '已转化', value: true },
],
placeholder: '请选择转化状态',
clearable: true,
},
defaultValue: false,
},
{
fieldName: 'mobile',
label: '手机号',
component: 'Input',
componentProps: {
placeholder: '请输入手机号',
clearable: true,
},
},
{
fieldName: 'telephone',
label: '电话',
component: 'Input',
componentProps: {
placeholder: '请输入电话',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
placeholder: ['开始日期', '结束日期'],
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '线索名称',
fixed: 'left',
minWidth: 160,
slots: {
default: 'name',
},
},
{
field: 'source',
title: '线索来源',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_SOURCE },
},
},
{
field: 'mobile',
title: '手机',
minWidth: 120,
},
{
field: 'telephone',
title: '电话',
minWidth: 130,
},
{
field: 'email',
title: '邮箱',
minWidth: 180,
},
{
field: 'detailAddress',
title: '地址',
minWidth: 180,
},
{
field: 'industryId',
title: '客户行业',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY },
},
},
{
field: 'level',
title: '客户级别',
minWidth: 135,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_LEVEL },
},
},
{
field: 'contactNextTime',
title: '下次联系时间',
formatter: 'formatDateTime',
minWidth: 180,
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'contactLastTime',
title: '最后跟进时间',
formatter: 'formatDateTime',
minWidth: 180,
},
{
field: 'contactLastContent',
title: '最后跟进记录',
minWidth: 200,
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 100,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'updateTime',
title: '更新时间',
formatter: 'formatDateTime',
minWidth: 180,
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
minWidth: 180,
},
{
title: '操作',
width: 140,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,111 @@
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
/** 详情头部的配置 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'source',
label: '线索来源',
render: (val) =>
h(DictTag, {
type: DICT_TYPE.CRM_CUSTOMER_SOURCE,
value: val,
}),
},
{
field: 'mobile',
label: '手机',
},
{
field: 'ownerUserName',
label: '负责人',
},
{
field: 'createTime',
label: '创建时间',
render: (val) => formatDateTime(val) as string,
},
];
}
/** 详情基本信息的配置 */
export function useDetailBaseSchema(): DescriptionItemSchema[] {
return [
{
field: 'name',
label: '线索名称',
},
{
field: 'source',
label: '客户来源',
render: (val) =>
h(DictTag, {
type: DICT_TYPE.CRM_CUSTOMER_SOURCE,
value: val,
}),
},
{
field: 'mobile',
label: '手机',
},
{
field: 'telephone',
label: '电话',
},
{
field: 'email',
label: '邮箱',
},
{
field: 'areaName',
label: '地址',
render: (val, data) => {
const areaName = val ?? '';
const detailAddress = data?.detailAddress ?? '';
return [areaName, detailAddress].filter((item) => !!item).join(' ');
},
},
{
field: 'qq',
label: 'QQ',
},
{
field: 'wechat',
label: '微信',
},
{
field: 'industryId',
label: '客户行业',
render: (val) =>
h(DictTag, {
type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY,
value: val,
}),
},
{
field: 'level',
label: '客户级别',
render: (val) =>
h(DictTag, {
type: DICT_TYPE.CRM_CUSTOMER_LEVEL,
value: val,
}),
},
{
field: 'contactNextTime',
label: '下次联系时间',
render: (val) => formatDateTime(val) as string,
},
{
field: 'remark',
label: '备注',
},
];
}

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import type { CrmClueApi } from '#/api/crm/clue';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { ElCard, ElMessage, ElTabPane, ElTabs } from 'element-plus';
import { getClue, transformClue } from '#/api/crm/clue';
import { getOperateLogPage } from '#/api/crm/operateLog';
import { BizTypeEnum } from '#/api/crm/permission';
import { useDescription } from '#/components/description';
import { OperateLog } from '#/components/operate-log';
import { ACTION_ICON, TableAction } from '#/components/table-action';
import { FollowUp } from '#/views/crm/followup';
import { PermissionList, TransferForm } from '#/views/crm/permission';
import Form from '../modules/form.vue';
import { useDetailSchema } from './data';
import Info from './modules/info.vue';
const route = useRoute();
const router = useRouter();
const tabs = useTabs();
const loading = ref(false); // 加载中
const clueId = ref(0); // 线索编号
const clue = ref<CrmClueApi.Clue>({} as CrmClueApi.Clue); // 线索详情
const logList = ref<SystemOperateLogApi.OperateLog[]>([]); // 操作日志
const permissionListRef = ref<InstanceType<typeof PermissionList>>(); // 团队成员列表 Ref
const activeTabName = ref('1'); // 选中 Tab 名
const [Descriptions] = useDescription({
border: false,
column: 4,
schema: useDetailSchema(),
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [TransferModal, transferModalApi] = useVbenModal({
connectedComponent: TransferForm,
destroyOnClose: true,
});
/** 加载线索详情 */
async function getClueDetail() {
loading.value = true;
try {
clue.value = await getClue(clueId.value);
// 操作日志
const res = await getOperateLogPage({
bizType: BizTypeEnum.CRM_CLUE,
bizId: clueId.value,
});
logList.value = res.list;
} finally {
loading.value = false;
}
}
/** 返回列表页 */
function handleBack() {
tabs.closeCurrentTab();
router.push({ name: 'CrmClue' });
}
/** 编辑线索 */
function handleEdit() {
formModalApi.setData({ id: clueId.value }).open();
}
/** 转移线索 */
function handleTransfer() {
transferModalApi.setData({ bizType: BizTypeEnum.CRM_CLUE }).open();
}
/** 转化为客户 */
async function handleTransform(): Promise<boolean | undefined> {
return new Promise((resolve, reject) => {
confirm({
content: '确定将该线索转化为客户吗?',
})
.then(async () => {
// 转化为客户
await transformClue(clueId.value);
// 提示并返回成功
ElMessage.success('转化客户成功');
resolve(true);
})
.catch(() => {
reject(new Error('取消操作'));
});
});
}
/** 加载数据 */
onMounted(() => {
clueId.value = Number(route.params.id);
getClueDetail();
});
</script>
<template>
<Page auto-content-height :title="clue?.name" :loading="loading">
<FormModal @success="getClueDetail" />
<TransferModal @success="getClueDetail" />
<template #extra>
<TableAction
:actions="[
{
label: '返回',
type: 'default',
icon: 'lucide:arrow-left',
onClick: handleBack,
},
{
label: $t('ui.actionTitle.edit'),
type: 'primary',
icon: ACTION_ICON.EDIT,
auth: ['crm:clue:update'],
ifShow: permissionListRef?.validateWrite,
onClick: handleEdit,
},
{
label: '转移',
type: 'primary',
ifShow: permissionListRef?.validateOwnerUser,
onClick: handleTransfer,
},
{
label: '转化为客户',
type: 'primary',
ifShow:
permissionListRef?.validateOwnerUser && !clue?.transformStatus,
onClick: handleTransform,
},
]"
/>
</template>
<ElCard class="min-h-[10%]">
<Descriptions :data="clue" />
</ElCard>
<ElCard class="mt-4 min-h-[60%]">
<ElTabs v-model:model-value="activeTabName">
<ElTabPane label="跟进记录" name="1">
<FollowUp :biz-id="clueId" :biz-type="BizTypeEnum.CRM_CLUE" />
</ElTabPane>
<ElTabPane label="基本信息" name="2">
<Info :clue="clue" />
</ElTabPane>
<ElTabPane label="团队成员" name="3">
<PermissionList
ref="permissionListRef"
:biz-id="clueId"
:biz-type="BizTypeEnum.CRM_CLUE"
:show-action="true"
@quit-team="handleBack"
/>
</ElTabPane>
<ElTabPane label="操作日志" name="4">
<OperateLog :log-list="logList" />
</ElTabPane>
</ElTabs>
</ElCard>
</Page>
</template>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import type { CrmClueApi } from '#/api/crm/clue';
import { ElDivider } from 'element-plus';
import { useDescription } from '#/components/description';
import { useFollowUpDetailSchema } from '#/views/crm/followup/data';
import { useDetailBaseSchema } from '../data';
defineProps<{
clue: CrmClueApi.Clue;
}>();
const [BaseDescriptions] = useDescription({
title: '基本信息',
border: false,
column: 4,
schema: useDetailBaseSchema(),
});
const [SystemDescriptions] = useDescription({
title: '系统信息',
border: false,
column: 3,
schema: useFollowUpDetailSchema(),
});
</script>
<template>
<div>
<BaseDescriptions :data="clue" />
<ElDivider />
<SystemDescriptions :data="clue" />
</div>
</template>

View File

@@ -0,0 +1,193 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmClueApi } from '#/api/crm/clue';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import {
ElButton,
ElLoading,
ElMessage,
ElTabPane,
ElTabs,
} from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteClue, exportClue, getCluePage } from '#/api/crm/clue';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const { push } = useRouter();
const sceneType = ref('1');
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportClue({
sceneType: sceneType.value,
...(await gridApi.formApi.getValues()),
});
downloadFileFromBlobPart({ fileName: '线索.xls', source: data });
}
/** 创建线索 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑线索 */
function handleEdit(row: CrmClueApi.Clue) {
formModalApi.setData(row).open();
}
/** 删除线索 */
async function handleDelete(row: CrmClueApi.Clue) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
});
try {
await deleteClue(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 查看线索详情 */
function handleDetail(row: CrmClueApi.Clue) {
push({ name: 'CrmClueDetail', params: { id: row.id } });
}
/** 处理场景类型的切换 */
function handleChangeSceneType(key: number | string) {
sceneType.value = key.toString();
gridApi.query();
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCluePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
sceneType: sceneType.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmClueApi.Clue>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【线索】线索管理"
url="https://doc.iocoder.cn/crm/clue/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal @success="handleRefresh" />
<Grid>
<template #toolbar-actions>
<ElTabs
class="w-full"
@tab-change="handleChangeSceneType"
v-model:model-value="sceneType"
>
<ElTabPane label="我负责的" name="1" />
<ElTabPane label="我参与的" name="2" />
<ElTabPane label="下属负责的" name="3" />
</ElTabs>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['线索']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:clue:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['crm:clue:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['crm:clue:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:clue:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
import type { CrmClueApi } from '#/api/crm/clue';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { createClue, getClue, updateClue } from '#/api/crm/clue';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmClueApi.Clue>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['线索'])
: $t('ui.actionTitle.create', ['线索']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
wrapperClass: 'grid-cols-2',
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as CrmClueApi.Clue;
try {
await (formData.value?.id ? updateClue(data) : createClue(data));
// 关闭并提示
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<CrmClueApi.Clue>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getClue(data.id);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-2/5">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,62 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
/** 联系人明细列表列配置 */
export function useDetailListColumns(): VxeTableGridOptions['columns'] {
return [
{
type: 'checkbox',
width: 50,
fixed: 'left',
},
{
field: 'name',
title: '姓名',
fixed: 'left',
slots: { default: 'name' },
},
{
field: 'customerName',
title: '客户名称',
fixed: 'left',
slots: { default: 'customerName' },
},
{
field: 'sex',
title: '性别',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_USER_SEX },
},
},
{
field: 'mobile',
title: '手机',
},
{
field: 'telephone',
title: '电话',
},
{
field: 'email',
title: '邮箱',
},
{
field: 'post',
title: '职位',
},
{
field: 'detailAddress',
title: '地址',
},
{
field: 'master',
title: '关键决策人',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
];
}

View File

@@ -0,0 +1,148 @@
<!-- 联系人列表的选择用于商机详情中选择它要关联的联系人 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmContactApi } from '#/api/crm/contact';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import { ElButton, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getContactPageByCustomer } from '#/api/crm/contact';
import { $t } from '#/locales';
import Form from '../modules/form.vue';
import { useDetailListColumns } from './data';
const props = defineProps<{
customerId?: number; // 关联联系人与商机时,需要传入 customerId 进行筛选
}>();
const emit = defineEmits(['success']);
const { push } = useRouter();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const checkedRows = ref<CrmContactApi.Contact[]>([]);
function setCheckedRows({ records }: { records: CrmContactApi.Contact[] }) {
checkedRows.value = records;
}
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建商机 */
function handleCreate() {
formModalApi.setData({ customerId: props.customerId }).open();
}
/** 查看商机详情 */
function handleDetail(row: CrmContactApi.Contact) {
push({ name: 'CrmContactDetail', params: { id: row.id } });
}
/** 查看客户详情 */
function handleCustomerDetail(row: CrmContactApi.Contact) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
if (checkedRows.value.length === 0) {
ElMessage.error('请先选择联系人后操作!');
return;
}
modalApi.lock();
// 提交表单
try {
const contactIds = checkedRows.value.map((item) => item.id);
// 关闭并提示
await modalApi.close();
emit('success', contactIds, checkedRows.value);
} finally {
modalApi.unlock();
}
},
});
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: [
{
fieldName: 'name',
label: '联系人名称',
component: 'Input',
},
],
},
gridOptions: {
columns: useDetailListColumns(),
height: 600,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getContactPageByCustomer({
pageNo: page.currentPage,
pageSize: page.pageSize,
customerId: props.customerId,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmContactApi.Contact>,
gridEvents: {
checkboxAll: setCheckedRows,
checkboxChange: setCheckedRows,
},
});
</script>
<template>
<Modal title="关联联系人" class="w-2/5">
<FormModal @success="handleRefresh" />
<Grid>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['联系人']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:contact:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #customerName="{ row }">
<ElButton type="primary" link @click="handleCustomerDetail(row)">
{{ row.customerName }}
</ElButton>
</template>
</Grid>
</Modal>
</template>

View File

@@ -0,0 +1,210 @@
<!-- 联系人列表用于联系人商机详情中展示它们关联的联系人列表 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmContactApi } from '#/api/crm/contact';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { confirm, useVbenModal } from '@vben/common-ui';
import { ElButton, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
createBusinessContactList,
deleteBusinessContactList,
getContactPageByBusiness,
getContactPageByCustomer,
} from '#/api/crm/contact';
import { BizTypeEnum } from '#/api/crm/permission';
import { $t } from '#/locales';
import Form from '../modules/form.vue';
import { useDetailListColumns } from './data';
import ListModal from './detail-list-modal.vue';
const props = defineProps<{
bizId: number; // 业务编号
bizType: number; // 业务类型
businessId?: number; // 特殊:商机编号;在【商机】详情中,可以传递商机编号,默认新建的联系人关联到该商机
customerId?: number; // 特殊:客户编号;在【商机】详情中,可以传递客户编号,默认新建的联系人关联到该客户
}>();
const { push } = useRouter();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [DetailListModal, detailListModalApi] = useVbenModal({
connectedComponent: ListModal,
destroyOnClose: true,
});
const checkedRows = ref<CrmContactApi.Contact[]>([]);
function setCheckedRows({ records }: { records: CrmContactApi.Contact[] }) {
checkedRows.value = records;
}
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建联系人 */
function handleCreate() {
formModalApi
.setData({ customerId: props.customerId, businessId: props.businessId })
.open();
}
/** 关联联系人 */
function handleCreateContact() {
detailListModalApi.setData({ customerId: props.customerId }).open();
}
/** 解除联系人关联 */
async function handleDeleteContactBusinessList() {
if (checkedRows.value.length === 0) {
ElMessage.error('请先选择联系人后操作!');
return;
}
return new Promise((resolve, reject) => {
confirm({
content: `确定要将${checkedRows.value.map((item) => item.name).join(',')}解除关联吗?`,
})
.then(async () => {
const res = await deleteBusinessContactList({
businessId: props.bizId,
contactIds: checkedRows.value.map((item) => item.id),
});
if (res) {
// 提示并返回成功
ElMessage.success($t('ui.actionMessage.operationSuccess'));
handleRefresh();
resolve(true);
} else {
reject(new Error($t('ui.actionMessage.operationFailed')));
}
})
.catch(() => {
reject(new Error('取消操作'));
});
});
}
/** 创建商机联系人关联 */
async function handleCreateBusinessContactList(contactIds: number[]) {
const data = {
businessId: props.bizId,
contactIds,
} as CrmContactApi.BusinessContactReqVO;
await createBusinessContactList(data);
handleRefresh();
}
/** 查看联系人详情 */
function handleDetail(row: CrmContactApi.Contact) {
push({ name: 'CrmContactDetail', params: { id: row.id } });
}
/** 查看客户详情 */
function handleCustomerDetail(row: CrmContactApi.Contact) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useDetailListColumns(),
height: 600,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
if (props.bizType === BizTypeEnum.CRM_CUSTOMER) {
return await getContactPageByCustomer({
pageNo: page.currentPage,
pageSize: page.pageSize,
customerId: props.bizId,
...formValues,
});
} else if (props.bizType === BizTypeEnum.CRM_BUSINESS) {
return await getContactPageByBusiness({
pageNo: page.currentPage,
pageSize: page.pageSize,
businessId: props.bizId,
...formValues,
});
} else {
return [];
}
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmContactApi.Contact>,
gridEvents: {
checkboxAll: setCheckedRows,
checkboxChange: setCheckedRows,
},
});
</script>
<template>
<div>
<FormModal @success="handleRefresh" />
<DetailListModal
:customer-id="customerId"
@success="handleCreateBusinessContactList"
/>
<Grid>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['联系人']),
type: 'primary',
icon: ACTION_ICON.ADD,
onClick: handleCreate,
},
{
label: '关联',
icon: ACTION_ICON.ADD,
type: 'default',
auth: ['crm:contact:create-business'],
ifShow: () => !!businessId,
onClick: handleCreateContact,
},
{
label: '解除关联',
icon: ACTION_ICON.ADD,
type: 'default',
auth: ['crm:contact:create-business'],
ifShow: () => !!businessId,
onClick: handleDeleteContactBusinessList,
},
]"
/>
</template>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #customerName="{ row }">
<ElButton type="primary" link @click="handleCustomerDetail(row)">
{{ row.customerName }}
</ElButton>
</template>
</Grid>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as ContactDetailsList } from './detail-list.vue';

View File

@@ -0,0 +1,366 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { useUserStore } from '@vben/stores';
import { getSimpleContactList } from '#/api/crm/contact';
import { getCustomerSimpleList } from '#/api/crm/customer';
import { getAreaTree } from '#/api/system/area';
import { getSimpleUserList } from '#/api/system/user';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
const userStore = useUserStore();
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '联系人姓名',
component: 'Input',
rules: 'required',
componentProps: {
placeholder: '请输入联系人姓名',
},
},
{
fieldName: 'ownerUserId',
label: '负责人',
component: 'ApiSelect',
rules: 'required',
dependencies: {
triggerFields: ['id'],
disabled: (values) => values.id,
},
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择负责人',
},
defaultValue: userStore.userInfo?.id,
},
{
fieldName: 'customerId',
label: '客户名称',
component: 'ApiSelect',
rules: 'required',
componentProps: {
api: getCustomerSimpleList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择客户',
},
},
{
fieldName: 'mobile',
label: '手机',
component: 'Input',
componentProps: {
placeholder: '请输入手机号',
},
},
{
fieldName: 'telephone',
label: '电话',
component: 'Input',
componentProps: {
placeholder: '请输入电话',
},
},
{
fieldName: 'email',
label: '邮箱',
component: 'Input',
componentProps: {
placeholder: '请输入邮箱',
},
},
{
fieldName: 'wechat',
label: '微信',
component: 'Input',
componentProps: {
placeholder: '请输入微信',
},
},
{
fieldName: 'qq',
label: 'QQ',
component: 'Input',
componentProps: {
placeholder: '请输入QQ',
},
},
{
fieldName: 'post',
label: '职位',
component: 'Input',
componentProps: {
placeholder: '请输入职位',
},
},
{
fieldName: 'master',
label: '关键决策人',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
placeholder: '请选择是否关键决策人',
},
defaultValue: false,
},
{
fieldName: 'sex',
label: '性别',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'),
placeholder: '请选择性别',
},
},
{
fieldName: 'parentId',
label: '直属上级',
component: 'ApiSelect',
componentProps: {
api: getSimpleContactList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择直属上级',
},
},
{
fieldName: 'areaId',
label: '地址',
component: 'ApiTreeSelect',
componentProps: {
api: getAreaTree,
fieldNames: { label: 'name', value: 'id', children: 'children' },
placeholder: '请选择地址',
},
},
{
fieldName: 'detailAddress',
label: '详细地址',
component: 'Input',
componentProps: {
placeholder: '请输入详细地址',
},
},
{
fieldName: 'contactNextTime',
label: '下次联系时间',
component: 'DatePicker',
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
placeholder: '请选择下次联系时间',
class: '!w-full',
},
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'customerId',
label: '客户',
component: 'ApiSelect',
componentProps: {
api: getCustomerSimpleList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择客户',
},
},
{
fieldName: 'name',
label: '姓名',
component: 'Input',
componentProps: {
placeholder: '请输入联系人姓名',
allowClear: true,
},
},
{
fieldName: 'mobile',
label: '手机号',
component: 'Input',
componentProps: {
placeholder: '请输入手机号',
allowClear: true,
},
},
{
fieldName: 'telephone',
label: '电话',
component: 'Input',
componentProps: {
placeholder: '请输入电话',
allowClear: true,
},
},
{
fieldName: 'wechat',
label: '微信',
component: 'Input',
componentProps: {
placeholder: '请输入微信',
allowClear: true,
},
},
{
fieldName: 'email',
label: '电子邮箱',
component: 'Input',
componentProps: {
placeholder: '请输入电子邮箱',
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '联系人姓名',
fixed: 'left',
minWidth: 240,
slots: { default: 'name' },
},
{
field: 'customerName',
title: '客户名称',
fixed: 'left',
minWidth: 240,
slots: { default: 'customerName' },
},
{
field: 'mobile',
title: '手机',
minWidth: 120,
},
{
field: 'telephone',
title: '电话',
minWidth: 130,
},
{
field: 'email',
title: '邮箱',
minWidth: 180,
},
{
field: 'post',
title: '职位',
minWidth: 120,
},
{
field: 'areaName',
title: '地址',
minWidth: 120,
},
{
field: 'detailAddress',
title: '详细地址',
minWidth: 180,
},
{
field: 'master',
title: '关键决策人',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'parentId',
title: '直属上级',
minWidth: 120,
slots: { default: 'parentId' },
},
{
field: 'contactNextTime',
title: '下次联系时间',
formatter: 'formatDateTime',
minWidth: 180,
},
{
field: 'sex',
title: '性别',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_USER_SEX },
},
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'contactLastTime',
title: '最后跟进时间',
formatter: 'formatDateTime',
minWidth: 180,
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 120,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 120,
},
{
field: 'updateTime',
title: '更新时间',
formatter: 'formatDateTime',
minWidth: 180,
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
minWidth: 180,
},
{
field: 'creatorName',
title: '创建人',
minWidth: 120,
},
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,106 @@
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { formatDateTime } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
/** 详情页的基础字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'customerName',
label: '客户名称',
},
{
field: 'post',
label: '职务',
},
{
field: 'mobile',
label: '手机',
},
{
field: 'createTime',
label: '创建时间',
render: (val) => formatDateTime(val) as string,
},
];
}
/** 详情页的基础字段 */
export function useDetailBaseSchema(): DescriptionItemSchema[] {
return [
{
field: 'name',
label: '姓名',
},
{
field: 'customerName',
label: '客户名称',
},
{
field: 'mobile',
label: '手机',
},
{
field: 'telephone',
label: '电话',
},
{
field: 'email',
label: '邮箱',
},
{
field: 'qq',
label: 'QQ',
},
{
field: 'wechat',
label: '微信',
},
{
field: 'areaName',
label: '地址',
render: (val, data) => {
const areaName = val ?? '';
const detailAddress = data?.detailAddress ?? '';
return [areaName, detailAddress].filter((item) => !!item).join(' ');
},
},
{
field: 'post',
label: '职务',
},
{
field: 'parentName',
label: '直属上级',
},
{
field: 'master',
label: '关键决策人',
render: (val) =>
h(DictTag, {
type: DICT_TYPE.INFRA_BOOLEAN_STRING,
value: val,
}),
},
{
field: 'sex',
label: '性别',
render: (val) =>
h(DictTag, { type: DICT_TYPE.SYSTEM_USER_SEX, value: val }),
},
{
field: 'contactNextTime',
label: '下次联系时间',
render: (val) => formatDateTime(val) as string,
},
{
field: 'remark',
label: '备注',
},
];
}

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
import type { CrmContactApi } from '#/api/crm/contact';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { ElCard, ElTabPane, ElTabs } from 'element-plus';
import { getContact } from '#/api/crm/contact';
import { getOperateLogPage } from '#/api/crm/operateLog';
import { BizTypeEnum } from '#/api/crm/permission';
import { useDescription } from '#/components/description';
import { OperateLog } from '#/components/operate-log';
import { ACTION_ICON, TableAction } from '#/components/table-action';
import { $t } from '#/locales';
import { BusinessDetailsList } from '#/views/crm/business/components';
import { FollowUp } from '#/views/crm/followup';
import { PermissionList, TransferForm } from '#/views/crm/permission';
import Form from '../modules/form.vue';
import { useDetailSchema } from './data';
import Info from './modules/info.vue';
const route = useRoute();
const router = useRouter();
const tabs = useTabs();
const loading = ref(false); // 加载中
const contactId = ref(0); // 联系人编号
const contact = ref<CrmContactApi.Contact>({} as CrmContactApi.Contact); // 联系人详情
const activeTabName = ref('1'); // 选中 Tab 名
const logList = ref<SystemOperateLogApi.OperateLog[]>([]); // 操作日志
const permissionListRef = ref<InstanceType<typeof PermissionList>>(); // 团队成员列表 Ref
const [Descriptions] = useDescription({
border: false,
column: 4,
schema: useDetailSchema(),
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [TransferModal, transferModalApi] = useVbenModal({
connectedComponent: TransferForm,
destroyOnClose: true,
});
/** 加载联系人详情 */
async function getContactDetail() {
loading.value = true;
try {
contact.value = await getContact(contactId.value);
// 操作日志
const res = await getOperateLogPage({
bizType: BizTypeEnum.CRM_CONTACT,
bizId: contactId.value,
});
logList.value = res.list;
} finally {
loading.value = false;
}
}
/** 返回列表页 */
function handleBack() {
tabs.closeCurrentTab();
router.push({ name: 'CrmContact' });
}
/** 编辑联系人 */
function handleEdit() {
formModalApi.setData({ id: contactId.value }).open();
}
/** 转移联系人 */
function handleTransfer() {
transferModalApi.setData({ id: contactId.value }).open();
}
/** 加载数据 */
onMounted(() => {
contactId.value = Number(route.params.id);
getContactDetail();
});
</script>
<template>
<Page auto-content-height :title="contact?.name" :loading="loading">
<FormModal @success="getContactDetail" />
<TransferModal @success="getContactDetail" />
<template #extra>
<TableAction
:actions="[
{
label: '返回',
type: 'default',
icon: 'lucide:arrow-left',
onClick: handleBack,
},
{
label: $t('ui.actionTitle.edit'),
type: 'primary',
icon: ACTION_ICON.EDIT,
auth: ['crm:contact:update'],
ifShow: permissionListRef?.validateWrite,
onClick: handleEdit,
},
{
label: '转移',
type: 'primary',
ifShow: permissionListRef?.validateOwnerUser,
onClick: handleTransfer,
},
]"
/>
</template>
<ElCard class="min-h-[10%]">
<Descriptions :data="contact" />
</ElCard>
<ElCard class="mt-4 min-h-[60%]">
<ElTabs v-model:model-value="activeTabName">
<ElTabPane label="跟进记录" name="1">
<FollowUp :biz-id="contactId" :biz-type="BizTypeEnum.CRM_CONTACT" />
</ElTabPane>
<ElTabPane label="详细资料" name="2">
<Info :contact="contact" />
</ElTabPane>
<ElTabPane label="操作日志" name="3">
<OperateLog :log-list="logList" />
</ElTabPane>
<ElTabPane label="团队成员" name="4">
<PermissionList
ref="permissionListRef"
:biz-id="contactId"
:biz-type="BizTypeEnum.CRM_CONTACT"
:show-action="true"
@quit-team="handleBack"
/>
</ElTabPane>
<ElTabPane label="商机" name="5">
<BusinessDetailsList
:biz-id="contactId"
:biz-type="BizTypeEnum.CRM_CONTACT"
:contact-id="contactId"
:customer-id="contact.customerId"
/>
</ElTabPane>
</ElTabs>
</ElCard>
</Page>
</template>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import type { CrmContactApi } from '#/api/crm/contact';
import { ElDivider } from 'element-plus';
import { useDescription } from '#/components/description';
import { useFollowUpDetailSchema } from '#/views/crm/followup/data';
import { useDetailBaseSchema } from '../data';
defineProps<{
contact: CrmContactApi.Contact;
}>();
const [BaseDescriptions] = useDescription({
title: '基本信息',
border: false,
column: 4,
schema: useDetailBaseSchema(),
});
const [SystemDescriptions] = useDescription({
title: '系统信息',
border: false,
column: 3,
schema: useFollowUpDetailSchema(),
});
</script>
<template>
<div>
<BaseDescriptions :data="contact" />
<ElDivider />
<SystemDescriptions :data="contact" />
</div>
</template>

View File

@@ -0,0 +1,213 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmContactApi } from '#/api/crm/contact';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import {
ElButton,
ElLoading,
ElMessage,
ElTabPane,
ElTabs,
} from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteContact,
exportContact,
getContactPage,
} from '#/api/crm/contact';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const { push } = useRouter();
const sceneType = ref('1');
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 处理场景类型的切换 */
function handleChangeSceneType(key: number | string) {
sceneType.value = key.toString();
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const formValues = await gridApi.formApi.getValues();
const data = await exportContact({
sceneType: sceneType.value,
...formValues,
});
downloadFileFromBlobPart({ fileName: '联系人.xls', source: data });
}
/** 创建联系人 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑联系人 */
function handleEdit(row: CrmContactApi.Contact) {
formModalApi.setData(row).open();
}
/** 删除联系人 */
async function handleDelete(row: CrmContactApi.Contact) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
});
try {
await deleteContact(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 查看联系人详情 */
function handleDetail(row: CrmContactApi.Contact) {
push({ name: 'CrmContactDetail', params: { id: row.id } });
}
/** 查看客户详情 */
function handleCustomerDetail(row: CrmContactApi.Contact) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getContactPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
sceneType: sceneType.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmContactApi.Contact>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【客户】客户管理、公海客户"
url="https://doc.iocoder.cn/crm/customer/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal @success="handleRefresh" />
<Grid>
<template #toolbar-actions>
<ElTabs
class="w-full"
@tab-change="handleChangeSceneType"
v-model:model-value="sceneType"
>
<ElTabPane label="我负责的" name="1" />
<ElTabPane label="我参与的" name="2" />
<ElTabPane label="下属负责的" name="3" />
</ElTabs>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['联系人']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:contact:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['crm:contact:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #customerName="{ row }">
<ElButton type="primary" link @click="handleCustomerDetail(row)">
{{ row.customerName }}
</ElButton>
</template>
<template #parentId="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.parentName }}
</ElButton>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['crm:contact:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:contact:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,80 @@
<script lang="ts" setup>
import type { CrmContactApi } from '#/api/crm/contact';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { createContact, getContact, updateContact } from '#/api/crm/contact';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmContactApi.Contact>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['联系人'])
: $t('ui.actionTitle.create', ['联系人']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
},
wrapperClass: 'grid-cols-2',
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as CrmContactApi.Contact;
try {
await (formData.value?.id ? updateContact(data) : createContact(data));
// 关闭并提示
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<CrmContactApi.Contact>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getContact(data.id);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-2/5">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,92 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter } from '@vben/utils';
export function useDetailListColumns(): VxeTableGridOptions['columns'] {
return [
{
title: '合同编号',
field: 'no',
minWidth: 150,
fixed: 'left',
},
{
title: '合同名称',
field: 'name',
minWidth: 150,
fixed: 'left',
slots: { default: 'name' },
},
{
title: '合同金额(元)',
field: 'totalPrice',
minWidth: 150,
formatter: 'formatAmount2',
},
{
title: '合同开始时间',
field: 'startTime',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '合同结束时间',
field: 'endTime',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '已回款金额(元)',
field: 'totalReceivablePrice',
minWidth: 150,
formatter: 'formatAmount2',
},
{
title: '未回款金额(元)',
field: 'unpaidPrice',
minWidth: 150,
formatter: ({ row }) => {
return erpPriceInputFormatter(
row.totalPrice - row.totalReceivablePrice,
);
},
},
{
title: '负责人',
field: 'ownerUserName',
minWidth: 150,
},
{
title: '所属部门',
field: 'ownerUserDeptName',
minWidth: 150,
},
{
title: '创建时间',
field: 'createTime',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '创建人',
field: 'creatorName',
minWidth: 150,
},
{
title: '备注',
field: 'remark',
minWidth: 150,
},
{
title: '合同状态',
field: 'auditStatus',
fixed: 'right',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
},
},
];
}

View File

@@ -0,0 +1,133 @@
<!-- 合同列表用于客户商机联系人详情中展示它们关联的合同列表 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmContractApi } from '#/api/crm/contract';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import { ElButton } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getContractPageByBusiness,
getContractPageByCustomer,
} from '#/api/crm/contract';
import { BizTypeEnum } from '#/api/crm/permission';
import { $t } from '#/locales';
import Form from '../modules/form.vue';
import { useDetailListColumns } from './data';
const props = defineProps<{
bizId: number; // 业务编号
bizType: number; // 业务类型
}>();
const { push } = useRouter();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 已选择的合同 */
const checkedRows = ref<CrmContractApi.Contract[]>();
function setCheckedRows({ records }: { records: CrmContractApi.Contract[] }) {
checkedRows.value = records;
}
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建合同 */
function handleCreate() {
formModalApi
.setData(
props.bizType === BizTypeEnum.CRM_CUSTOMER
? {
customerId: props.bizId,
}
: { businessId: props.bizId },
)
.open();
}
/** 查看合同详情 */
function handleDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContractDetail', params: { id: row.id } });
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useDetailListColumns(),
height: 600,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
if (props.bizType === BizTypeEnum.CRM_CUSTOMER) {
return await getContractPageByCustomer({
pageNo: page.currentPage,
pageSize: page.pageSize,
customerId: props.bizId,
...formValues,
});
} else if (props.bizType === BizTypeEnum.CRM_CONTACT) {
return await getContractPageByBusiness({
pageNo: page.currentPage,
pageSize: page.pageSize,
businessId: props.bizId,
...formValues,
});
} else {
return [];
}
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmContractApi.Contract>,
gridEvents: {
checkboxAll: setCheckedRows,
checkboxChange: setCheckedRows,
},
});
</script>
<template>
<div>
<FormModal @success="handleRefresh" />
<Grid>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['合同']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:contract:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
</Grid>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as ContractDetailsList } from './detail-list.vue';

View File

@@ -0,0 +1,40 @@
import type { VbenFormSchema } from '#/adapter/form';
import { z } from '#/adapter/form';
export const schema: VbenFormSchema[] = [
{
component: 'RadioGroup',
fieldName: 'notifyEnabled',
label: '提前提醒设置',
componentProps: {
options: [
{ label: '提醒', value: true },
{ label: '不提醒', value: false },
],
},
defaultValue: true,
},
{
component: 'Input',
fieldName: 'notifyDays',
componentProps: {
placeholder: '请输入天数',
class: '!w-full',
},
renderComponentContent: () => ({
prepend: () => '提前',
append: () => '天提醒',
}),
rules: z.coerce.number().int().min(0, '天数不能小于 0'),
dependencies: {
triggerFields: ['notifyEnabled'],
show: (values) => values.notifyEnabled,
trigger(values) {
if (!values.notifyEnabled) {
values.notifyDays = undefined;
}
},
},
},
];

View File

@@ -0,0 +1,61 @@
<script lang="ts" setup>
import type { CrmContractConfigApi } from '#/api/crm/contract/config';
import { onMounted } from 'vue';
import { Page } from '@vben/common-ui';
import { ElCard, ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
getContractConfig,
saveContractConfig,
} from '#/api/crm/contract/config';
import { $t } from '#/locales';
import { schema } from './data';
const [Form, formApi] = useVbenForm({
commonConfig: {
labelClass: 'w-100',
},
layout: 'horizontal',
schema,
handleSubmit,
});
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = (await formApi.getValues()) as CrmContractConfigApi.Config;
if (!data.notifyEnabled) {
data.notifyDays = undefined;
}
await saveContractConfig(data);
await formApi.setValues(data);
ElMessage.success($t('ui.actionMessage.operationSuccess'));
}
/** 获取配置 */
async function getConfigInfo() {
const res = await getContractConfig();
await formApi.setValues(res);
}
/** 初始化 */
onMounted(() => {
getConfigInfo();
});
</script>
<template>
<Page auto-content-height>
<ElCard title="合同配置设置">
<Form class="w-1/4" />
</ElCard>
</Page>
</template>

View File

@@ -0,0 +1,421 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { useUserStore } from '@vben/stores';
import { erpPriceInputFormatter, erpPriceMultiply } from '@vben/utils';
import { z } from '#/adapter/form';
import { getSimpleBusinessList } from '#/api/crm/business';
import { getSimpleContactList } from '#/api/crm/contact';
import { getCustomerSimpleList } from '#/api/crm/customer';
import { getSimpleUserList } from '#/api/system/user';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
const userStore = useUserStore();
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'no',
label: '合同编号',
component: 'Input',
componentProps: {
placeholder: '保存时自动生成',
disabled: true,
},
},
{
fieldName: 'name',
label: '合同名称',
component: 'Input',
rules: 'required',
componentProps: {
placeholder: '请输入合同名称',
},
},
{
fieldName: 'ownerUserId',
label: '负责人',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
},
dependencies: {
triggerFields: ['id'],
disabled: (values) => values.id,
},
defaultValue: userStore.userInfo?.id,
rules: 'required',
},
{
fieldName: 'customerId',
label: '客户名称',
component: 'ApiSelect',
rules: 'required',
componentProps: {
api: getCustomerSimpleList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择客户',
},
},
{
fieldName: 'businessId',
label: '商机名称',
component: 'Select',
componentProps: {
options: [],
placeholder: '请选择商机',
},
dependencies: {
triggerFields: ['customerId'],
disabled: (values) => !values.customerId,
async componentProps(values) {
if (!values.customerId) {
return {
options: [],
placeholder: '请选择客户',
};
}
const res = await getSimpleBusinessList();
const list = res.filter(
(item) => item.customerId === values.customerId,
);
return {
options: list.map((item) => ({
label: item.name,
value: item.id,
})),
placeholder: '请选择商机',
};
},
},
},
{
fieldName: 'orderDate',
label: '下单日期',
component: 'DatePicker',
rules: 'required',
componentProps: {
format: 'YYYY-MM-DD',
valueFormat: 'x',
placeholder: '请选择下单日期',
class: '!w-full',
},
},
{
fieldName: 'startTime',
label: '合同开始时间',
component: 'DatePicker',
componentProps: {
format: 'YYYY-MM-DD',
valueFormat: 'x',
placeholder: '请选择合同开始时间',
class: '!w-full',
},
},
{
fieldName: 'endTime',
label: '合同结束时间',
component: 'DatePicker',
componentProps: {
format: 'YYYY-MM-DD',
valueFormat: 'x',
placeholder: '请选择合同结束时间',
class: '!w-full',
},
},
{
fieldName: 'signUserId',
label: '公司签约人',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
},
defaultValue: userStore.userInfo?.id,
},
{
fieldName: 'signContactId',
label: '客户签约人',
component: 'Select',
componentProps: {
options: [],
placeholder: '请选择客户签约人',
},
dependencies: {
triggerFields: ['customerId'],
disabled: (values) => !values.customerId,
async componentProps(values) {
if (!values.customerId) {
return {
options: [],
placeholder: '请选择客户',
};
}
const res = await getSimpleContactList();
const list = res.filter(
(item) => item.customerId === values.customerId,
);
return {
options: list.map((item) => ({
label: item.name,
value: item.id,
})),
placeholder: '请选择客户签约人',
};
},
},
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
rows: 4,
},
},
{
fieldName: 'product',
label: '产品清单',
component: 'Input',
formItemClass: 'col-span-3',
},
{
fieldName: 'totalProductPrice',
label: '产品总金额',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
placeholder: '请输入产品总金额',
controlsPosition: 'right',
class: '!w-full',
},
rules: z.number().min(0).optional().default(0),
},
{
fieldName: 'discountPercent',
label: '整单折扣(%',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
placeholder: '请输入整单折扣',
controlsPosition: 'right',
class: '!w-full',
},
rules: z.number().min(0).max(100).optional().default(0),
},
{
fieldName: 'totalPrice',
label: '折扣后金额',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
disabled: true,
controlsPosition: 'right',
class: '!w-full',
},
dependencies: {
triggerFields: ['totalProductPrice', 'discountPercent'],
trigger(values, form) {
const discountPrice =
erpPriceMultiply(
values.totalProductPrice,
values.discountPercent / 100,
) ?? 0;
form.setFieldValue(
'totalPrice',
values.totalProductPrice - discountPrice,
);
},
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'no',
label: '合同编号',
component: 'Input',
componentProps: {
placeholder: '请输入合同编号',
clearable: true,
},
},
{
fieldName: 'name',
label: '合同名称',
component: 'Input',
componentProps: {
placeholder: '请输入合同名称',
clearable: true,
},
},
{
fieldName: 'customerId',
label: '客户',
component: 'ApiSelect',
componentProps: {
api: getCustomerSimpleList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择客户',
clearable: true,
},
},
];
}
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
title: '合同编号',
field: 'no',
minWidth: 180,
fixed: 'left',
},
{
title: '合同名称',
field: 'name',
minWidth: 160,
fixed: 'left',
slots: { default: 'name' },
},
{
title: '客户名称',
field: 'customerName',
minWidth: 120,
slots: { default: 'customerName' },
},
{
title: '商机名称',
field: 'businessName',
minWidth: 130,
slots: { default: 'businessName' },
},
{
title: '合同金额(元)',
field: 'totalPrice',
minWidth: 140,
formatter: 'formatAmount2',
},
{
title: '下单时间',
field: 'orderDate',
minWidth: 120,
formatter: 'formatDateTime',
},
{
title: '合同开始时间',
field: 'startTime',
minWidth: 120,
formatter: 'formatDateTime',
},
{
title: '合同结束时间',
field: 'endTime',
minWidth: 120,
formatter: 'formatDateTime',
},
{
title: '客户签约人',
field: 'signContactName',
minWidth: 130,
slots: { default: 'signContactName' },
},
{
title: '公司签约人',
field: 'signUserName',
minWidth: 130,
},
{
title: '备注',
field: 'remark',
minWidth: 200,
},
{
title: '已回款金额(元)',
field: 'totalReceivablePrice',
minWidth: 140,
formatter: 'formatAmount2',
},
{
title: '未回款金额(元)',
field: 'unReceivablePrice',
minWidth: 140,
formatter: ({ row }) => {
return erpPriceInputFormatter(
row.totalPrice - row.totalReceivablePrice,
);
},
},
{
title: '最后跟进时间',
field: 'contactLastTime',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '负责人',
field: 'ownerUserName',
minWidth: 120,
},
{
title: '所属部门',
field: 'ownerUserDeptName',
minWidth: 100,
},
{
title: '更新时间',
field: 'updateTime',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '创建时间',
field: 'createTime',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '创建人',
field: 'creatorName',
minWidth: 120,
},
{
title: '合同状态',
field: 'auditStatus',
fixed: 'right',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
},
},
{
title: '操作',
field: 'actions',
fixed: 'right',
minWidth: 130,
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,100 @@
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
/** 详情头部的配置 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'customerName',
label: '客户名称',
},
{
field: 'totalPrice',
label: '合同金额(元)',
render: (val) => erpPriceInputFormatter(val) as string,
},
{
field: 'orderDate',
label: '下单时间',
render: (val) => formatDateTime(val) as string,
},
{
field: 'totalReceivablePrice',
label: '回款金额(元)',
render: (val) => erpPriceInputFormatter(val) as string,
},
{
field: 'ownerUserName',
label: '负责人',
},
];
}
/** 详情基本信息的配置 */
export function useDetailBaseSchema(): DescriptionItemSchema[] {
return [
{
field: 'no',
label: '合同编号',
},
{
field: 'name',
label: '合同名称',
},
{
field: 'customerName',
label: '客户名称',
},
{
field: 'businessName',
label: '商机名称',
},
{
field: 'totalPrice',
label: '合同金额(元)',
render: (val) => erpPriceInputFormatter(val) as string,
},
{
field: 'orderDate',
label: '下单时间',
render: (val) => formatDateTime(val) as string,
},
{
field: 'startTime',
label: '合同开始时间',
render: (val) => formatDateTime(val) as string,
},
{
field: 'endTime',
label: '合同结束时间',
render: (val) => formatDateTime(val) as string,
},
{
field: 'signContactName',
label: '客户签约人',
},
{
field: 'signUserName',
label: '公司签约人',
},
{
field: 'remark',
label: '备注',
},
{
field: 'auditStatus',
label: '合同状态',
render: (val) =>
h(DictTag, {
type: DICT_TYPE.CRM_AUDIT_STATUS,
value: val,
}),
},
];
}

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import type { CrmContractApi } from '#/api/crm/contract';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { ElCard, ElTabPane, ElTabs } from 'element-plus';
import { getContract } from '#/api/crm/contract';
import { getOperateLogPage } from '#/api/crm/operateLog';
import { BizTypeEnum } from '#/api/crm/permission';
import { useDescription } from '#/components/description';
import { OperateLog } from '#/components/operate-log';
import { ACTION_ICON, TableAction } from '#/components/table-action';
import { $t } from '#/locales';
import { FollowUp } from '#/views/crm/followup';
import { PermissionList, TransferForm } from '#/views/crm/permission';
import { ProductDetailsList } from '#/views/crm/product/components';
import { ReceivableDetailsList } from '#/views/crm/receivable/components';
import { ReceivablePlanDetailsList } from '#/views/crm/receivable/plan/components';
import Form from '../modules/form.vue';
import { useDetailSchema } from './data';
import ContractDetailsInfo from './modules/info.vue';
const props = defineProps<{ id?: number }>();
const route = useRoute();
const router = useRouter();
const tabs = useTabs();
const loading = ref(false); // 加载中
const contractId = ref(0); // 合同编号
const contract = ref<CrmContractApi.Contract>({} as CrmContractApi.Contract); // 合同详情
const logList = ref<SystemOperateLogApi.OperateLog[]>([]); // 操作日志
const permissionListRef = ref<InstanceType<typeof PermissionList>>(); // 团队成员列表 Ref
const activeTabName = ref('1'); // 选中 Tab 名
const [Descriptions] = useDescription({
border: false,
column: 4,
schema: useDetailSchema(),
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [TransferModal, transferModalApi] = useVbenModal({
connectedComponent: TransferForm,
destroyOnClose: true,
});
/** 加载合同详情 */
async function loadContractDetail() {
loading.value = true;
try {
contract.value = await getContract(contractId.value);
// 操作日志
const res = await getOperateLogPage({
bizType: BizTypeEnum.CRM_CONTRACT,
bizId: contractId.value,
});
logList.value = res.list;
} finally {
loading.value = false;
}
}
/** 返回列表页 */
function handleBack() {
tabs.closeCurrentTab();
router.push({ name: 'CrmContract' });
}
/** 编辑合同 */
function handleEdit() {
formModalApi.setData({ id: contractId.value }).open();
}
/** 转移合同 */
function handleTransfer() {
transferModalApi.setData({ bizType: BizTypeEnum.CRM_CONTRACT }).open();
}
/** 加载数据 */
onMounted(() => {
contractId.value = Number(props.id || route.params.id);
loadContractDetail();
});
</script>
<template>
<Page auto-content-height :title="contract?.name" :loading="loading">
<FormModal @success="loadContractDetail" />
<TransferModal @success="loadContractDetail" />
<template #extra>
<TableAction
:actions="[
{
label: '返回',
type: 'default',
icon: 'lucide:arrow-left',
onClick: handleBack,
},
{
label: $t('ui.actionTitle.edit'),
type: 'primary',
icon: ACTION_ICON.EDIT,
auth: ['crm:contract:update'],
ifShow: permissionListRef?.validateWrite,
onClick: handleEdit,
},
{
label: '转移',
type: 'primary',
ifShow: permissionListRef?.validateOwnerUser,
onClick: handleTransfer,
},
]"
/>
</template>
<ElCard class="min-h-[10%]">
<Descriptions :data="contract" />
</ElCard>
<ElCard class="mt-4 min-h-[60%]">
<ElTabs v-model:model-value="activeTabName">
<ElTabPane label="跟进记录" name="1">
<FollowUp :biz-id="contractId" :biz-type="BizTypeEnum.CRM_CONTRACT" />
</ElTabPane>
<ElTabPane label="基本信息" name="2">
<ContractDetailsInfo :contract="contract" />
</ElTabPane>
<ElTabPane label="产品" name="3">
<ProductDetailsList
:biz-id="contractId"
:biz-type="BizTypeEnum.CRM_CONTRACT"
/>
</ElTabPane>
<ElTabPane label="回款" name="4" v-if="contract.customerId">
<ReceivablePlanDetailsList
:contract-id="contractId"
:customer-id="contract.customerId"
/>
<ReceivableDetailsList
:contract-id="contractId"
:customer-id="contract.customerId"
/>
</ElTabPane>
<ElTabPane label="团队成员" name="5">
<PermissionList
ref="permissionListRef"
:biz-id="contractId"
:biz-type="BizTypeEnum.CRM_CONTRACT"
:show-action="true"
@quit-team="handleBack"
/>
</ElTabPane>
<ElTabPane label="操作日志" name="6">
<OperateLog :log-list="logList" />
</ElTabPane>
</ElTabs>
</ElCard>
</Page>
</template>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import type { CrmContractApi } from '#/api/crm/contract';
import { ElDivider } from 'element-plus';
import { useDescription } from '#/components/description';
import { useFollowUpDetailSchema } from '#/views/crm/followup/data';
import { useDetailBaseSchema } from '../data';
defineProps<{
contract: CrmContractApi.Contract; // 合同信息
}>();
const [BaseDescriptions] = useDescription({
title: '基本信息',
border: false,
column: 4,
schema: useDetailBaseSchema(),
});
const [SystemDescriptions] = useDescription({
title: '系统信息',
border: false,
column: 3,
schema: useFollowUpDetailSchema(),
});
</script>
<template>
<div>
<BaseDescriptions :data="contract" />
<ElDivider />
<SystemDescriptions :data="contract" />
</div>
</template>

View File

@@ -0,0 +1,267 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmContractApi } from '#/api/crm/contract';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import {
ElButton,
ElLoading,
ElMessage,
ElTabPane,
ElTabs,
} from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteContract,
exportContract,
getContractPage,
submitContract,
} from '#/api/crm/contract';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const { push } = useRouter();
const sceneType = ref('1');
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 处理场景类型的切换 */
function handleChangeSceneType(key: number | string) {
sceneType.value = key.toString();
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const formValues = await gridApi.formApi.getValues();
const data = await exportContract({
sceneType: sceneType.value,
...formValues,
});
downloadFileFromBlobPart({ fileName: '合同.xls', source: data });
}
/** 创建合同 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑合同 */
function handleEdit(row: CrmContractApi.Contract) {
formModalApi.setData(row).open();
}
/** 删除合同 */
async function handleDelete(row: CrmContractApi.Contract) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
});
try {
await deleteContract(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 提交审核 */
async function handleSubmit(row: CrmContractApi.Contract) {
const loadingInstance = ElLoading.service({
text: '提交审核中...',
});
try {
await submitContract(row.id!);
ElMessage.success('提交审核成功');
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 查看合同详情 */
function handleDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContractDetail', params: { id: row.id } });
}
/** 查看客户详情 */
function handleCustomerDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 查看联系人详情 */
function handleContactDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmContactDetail', params: { id: row.signContactId } });
}
/** 查看商机详情 */
function handleBusinessDetail(row: CrmContractApi.Contract) {
push({ name: 'CrmBusinessDetail', params: { id: row.businessId } });
}
/** 查看审批详情 */
function handleProcessDetail(row: CrmContractApi.Contract) {
push({
name: 'BpmProcessInstanceDetail',
query: { id: row.processInstanceId },
});
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getContractPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
sceneType: sceneType.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmContractApi.Contract>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【合同】合同管理、合同提醒"
url="https://doc.iocoder.cn/crm/contract/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal @success="handleRefresh" />
<Grid>
<template #toolbar-actions>
<ElTabs
class="w-full"
v-model:model-value="sceneType"
@tab-change="handleChangeSceneType"
>
<ElTabPane label="我负责的" name="1" />
<ElTabPane label="我参与的" name="2" />
<ElTabPane label="下属负责的" name="3" />
</ElTabs>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['合同']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:contract:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['crm:contract:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #customerName="{ row }">
<ElButton type="primary" link @click="handleCustomerDetail(row)">
{{ row.customerName }}
</ElButton>
</template>
<template #businessName="{ row }">
<ElButton type="primary" link @click="handleBusinessDetail(row)">
{{ row.businessName }}
</ElButton>
</template>
<template #signContactName="{ row }">
<ElButton type="primary" link @click="handleContactDetail(row)">
{{ row.signContactName }}
</ElButton>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['crm:contract:update'],
onClick: handleEdit.bind(null, row),
ifShow: row.auditStatus === 0,
},
{
label: '提交审核',
type: 'primary',
link: true,
auth: ['crm:contract:update'],
onClick: handleSubmit.bind(null, row),
ifShow: row.auditStatus === 0,
},
{
label: '查看审批',
type: 'primary',
link: true,
auth: ['crm:contract:update'],
onClick: handleProcessDetail.bind(null, row),
ifShow: row.auditStatus !== 0,
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
auth: ['crm:contract:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,121 @@
<script lang="ts" setup>
import type { CrmContractApi } from '#/api/crm/contract';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { erpPriceMultiply } from '@vben/utils';
import { ElMessage } from 'element-plus';
import {
createContract,
getContract,
updateContract,
} from '#/api/crm/contract';
import { BizTypeEnum } from '#/api/crm/permission';
import { $t } from '#/locales';
import { ProductEditTable } from '#/views/crm/product/components';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmContractApi.Contract>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['合同'])
: $t('ui.actionTitle.create', ['合同']);
});
/** 更新产品列表 */
function handleUpdateProducts(products: any) {
formData.value = modalApi.getData<CrmContractApi.Contract>();
formData.value!.products = products;
if (formData.value) {
const totalProductPrice =
formData.value.products?.reduce(
(prev, curr) => prev + curr.totalPrice,
0,
) ?? 0;
const discountPercent = formData.value.discountPercent;
const discountPrice =
discountPercent === null
? 0
: erpPriceMultiply(totalProductPrice, discountPercent / 100);
const totalPrice = totalProductPrice - (discountPrice ?? 0);
formData.value!.totalProductPrice = totalProductPrice;
formData.value!.totalPrice = totalPrice;
formApi.setValues(formData.value!);
}
}
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 120,
},
wrapperClass: 'grid-cols-3',
layout: 'vertical',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as CrmContractApi.Contract;
data.products = formData.value?.products;
try {
await (formData.value?.id ? updateContract(data) : createContract(data));
// 关闭并提示
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<CrmContractApi.Contract>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getContract(data.id);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/2">
<Form class="mx-4">
<template #product="slotProps">
<ProductEditTable
v-bind="slotProps"
class="w-full"
:products="formData?.products ?? []"
:biz-type="BizTypeEnum.CRM_CONTRACT"
@update:products="handleUpdateProducts"
/>
</template>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,396 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { z } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { useUserStore } from '@vben/stores';
import { getAreaTree } from '#/api/system/area';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
const userStore = useUserStore();
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '客户名称',
component: 'Input',
rules: 'required',
componentProps: {
placeholder: '请输入客户名称',
allowClear: true,
},
},
{
fieldName: 'source',
label: '客户来源',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_SOURCE, 'number'),
placeholder: '请选择客户来源',
allowClear: true,
},
},
{
fieldName: 'mobile',
label: '手机',
component: 'Input',
componentProps: {
placeholder: '请输入手机',
allowClear: true,
},
},
{
fieldName: 'ownerUserId',
label: '负责人',
component: 'ApiSelect',
dependencies: {
triggerFields: ['id'],
disabled: (values) => values.id,
},
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择负责人',
allowClear: true,
},
defaultValue: userStore.userInfo?.id,
rules: 'required',
},
{
fieldName: 'telephone',
label: '电话',
component: 'Input',
componentProps: {
placeholder: '请输入电话',
allowClear: true,
},
},
{
fieldName: 'email',
label: '邮箱',
component: 'Input',
componentProps: {
placeholder: '请输入邮箱',
allowClear: true,
},
},
{
fieldName: 'wechat',
label: '微信',
component: 'Input',
componentProps: {
placeholder: '请输入微信',
allowClear: true,
},
},
{
fieldName: 'qq',
label: 'QQ',
component: 'Input',
componentProps: {
placeholder: '请输入QQ',
allowClear: true,
},
},
{
fieldName: 'industryId',
label: '客户行业',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_INDUSTRY, 'number'),
placeholder: '请选择客户行业',
allowClear: true,
},
},
{
fieldName: 'level',
label: '客户级别',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_LEVEL, 'number'),
placeholder: '请选择客户级别',
allowClear: true,
},
},
{
fieldName: 'areaId',
label: '地址',
component: 'ApiTreeSelect',
componentProps: {
api: getAreaTree,
fieldNames: { label: 'name', value: 'id', children: 'children' },
placeholder: '请选择地址',
allowClear: true,
},
},
{
fieldName: 'detailAddress',
label: '详细地址',
component: 'Input',
componentProps: {
placeholder: '请输入详细地址',
allowClear: true,
},
},
{
fieldName: 'contactNextTime',
label: '下次联系时间',
component: 'DatePicker',
componentProps: {
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
placeholder: '请选择下次联系时间',
allowClear: true,
class: '!w-full',
},
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
allowClear: true,
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '客户名称',
component: 'Input',
componentProps: {
placeholder: '请输入客户名称',
allowClear: true,
},
},
{
fieldName: 'mobile',
label: '手机号',
component: 'Input',
componentProps: {
placeholder: '请输入手机号',
allowClear: true,
},
},
{
fieldName: 'telephone',
label: '电话',
component: 'Input',
componentProps: {
placeholder: '请输入电话',
allowClear: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
placeholder: ['开始日期', '结束日期'],
allowClear: true,
},
},
];
}
/** 导入客户的表单 */
export function useImportFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'ownerUserId',
label: '负责人',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择负责人',
allowClear: true,
class: 'w-full',
},
dependencies: {
triggerFields: ['id'],
disabled: (values) => values.id,
},
rules: 'required',
},
{
fieldName: 'file',
label: '客户数据',
component: 'Upload',
rules: 'required',
help: '仅允许导入 xls、xlsx 格式文件',
},
{
fieldName: 'updateSupport',
label: '是否覆盖',
component: 'Switch',
componentProps: {
activeValue: true,
inactiveValue: false,
},
rules: z.boolean().default(false),
help: '是否更新已经存在的客户数据',
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '客户名称',
fixed: 'left',
minWidth: 160,
slots: { default: 'name' },
},
{
field: 'source',
title: '客户来源',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_SOURCE },
},
},
{
field: 'mobile',
title: '手机',
minWidth: 120,
},
{
field: 'telephone',
title: '电话',
minWidth: 130,
},
{
field: 'email',
title: '邮箱',
minWidth: 180,
},
{
field: 'level',
title: '客户级别',
minWidth: 135,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_LEVEL },
},
},
{
field: 'industryId',
title: '客户行业',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY },
},
},
{
field: 'contactNextTime',
title: '下次联系时间',
formatter: 'formatDateTime',
minWidth: 180,
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'lockStatus',
title: '锁定状态',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'dealStatus',
title: '成交状态',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'contactLastTime',
title: '最后跟进时间',
formatter: 'formatDateTime',
minWidth: 180,
},
{
field: 'contactLastContent',
title: '最后跟进记录',
minWidth: 200,
},
{
field: 'detailAddress',
title: '地址',
minWidth: 180,
},
{
field: 'poolDay',
title: '距离进入公海天数',
minWidth: 140,
formatter: ({ cellValue }) =>
cellValue === null ? '-' : `${cellValue}`,
},
{
field: 'ownerUserName',
title: '负责人',
minWidth: 100,
},
{
field: 'ownerUserDeptName',
title: '所属部门',
minWidth: 100,
},
{
field: 'updateTime',
title: '更新时间',
formatter: 'formatDateTime',
minWidth: 180,
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
minWidth: 180,
},
{
field: 'creatorName',
title: '创建人',
minWidth: 100,
},
{
title: '操作',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,130 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { useUserStore } from '@vben/stores';
import { formatDateTime } from '@vben/utils';
import { getSimpleUserList } from '#/api/system/user';
import { DictTag } from '#/components/dict-tag';
/** 分配客户表单 */
export function useDistributeFormSchema(): VbenFormSchema[] {
const userStore = useUserStore();
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'ownerUserId',
label: '负责人',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
},
defaultValue: userStore.userInfo?.id,
rules: 'required',
},
];
}
/** 详情页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'level',
label: '客户级别',
render: (val) =>
h(DictTag, { type: DICT_TYPE.CRM_CUSTOMER_LEVEL, value: val }),
},
{
field: 'dealStatus',
label: '成交状态',
render: (val) => (val ? '已成交' : '未成交'),
},
{
field: 'ownerUserName',
label: '负责人',
},
{
field: 'createTime',
label: '创建时间',
render: (val) => formatDateTime(val) as string,
},
];
}
/** 详情页的基础字段 */
export function useDetailBaseSchema(): DescriptionItemSchema[] {
return [
{
field: 'name',
label: '客户名称',
},
{
field: 'source',
label: '客户来源',
render: (val) =>
h(DictTag, {
type: DICT_TYPE.CRM_CUSTOMER_SOURCE,
value: val,
}),
},
{
field: 'mobile',
label: '手机',
},
{
field: 'telephone',
label: '电话',
},
{
field: 'email',
label: '邮箱',
},
{
field: 'areaName',
label: '地址',
render: (val, data) => {
const areaName = val ?? '';
const detailAddress = data?.detailAddress ?? '';
return [areaName, detailAddress].filter(Boolean).join(' ');
},
},
{
field: 'qq',
label: 'QQ',
},
{
field: 'wechat',
label: '微信',
},
{
field: 'industryId',
label: '客户行业',
render: (val) =>
h(DictTag, {
type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY,
value: val,
}),
},
{
field: 'contactNextTime',
label: '下次联系时间',
render: (val) => formatDateTime(val) as string,
},
{
field: 'remark',
label: '备注',
},
];
}

View File

@@ -0,0 +1,311 @@
<script setup lang="ts">
import type { CrmCustomerApi } from '#/api/crm/customer';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { ElCard, ElMessage, ElTabPane, ElTabs } from 'element-plus';
import {
getCustomer,
lockCustomer,
putCustomerPool,
receiveCustomer,
updateCustomerDealStatus,
} from '#/api/crm/customer';
import { getOperateLogPage } from '#/api/crm/operateLog';
import { BizTypeEnum } from '#/api/crm/permission';
import { useDescription } from '#/components/description';
import { OperateLog } from '#/components/operate-log';
import { ACTION_ICON, TableAction } from '#/components/table-action';
import { $t } from '#/locales';
import { BusinessDetailsList } from '#/views/crm/business/components';
import { ContactDetailsList } from '#/views/crm/contact/components';
import { ContractDetailsList } from '#/views/crm/contract/components';
import { FollowUp } from '#/views/crm/followup';
import { PermissionList, TransferForm } from '#/views/crm/permission';
import { ReceivableDetailsList } from '#/views/crm/receivable/components';
import { ReceivablePlanDetailsList } from '#/views/crm/receivable/plan/components';
import Form from '../modules/form.vue';
import { useDetailSchema } from './data';
import DistributeForm from './modules/distribute-form.vue';
import Info from './modules/info.vue';
const route = useRoute();
const router = useRouter();
const tabs = useTabs();
const loading = ref(false); // 加载中
const customerId = ref(0); // 客户编号
const customer = ref<CrmCustomerApi.Customer>({} as CrmCustomerApi.Customer); // 客户详情
const activeTabName = ref('1'); // 选中 Tab 名
const logList = ref<SystemOperateLogApi.OperateLog[]>([]); // 操作日志
const permissionListRef = ref<InstanceType<typeof PermissionList>>(); // 团队成员列表 Ref
const [Descriptions] = useDescription({
border: false,
column: 4,
schema: useDetailSchema(),
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [TransferModal, transferModalApi] = useVbenModal({
connectedComponent: TransferForm,
destroyOnClose: true,
});
const [DistributeModal, distributeModalApi] = useVbenModal({
connectedComponent: DistributeForm,
destroyOnClose: true,
});
/** 加载客户详情 */
async function loadCustomerDetail() {
loading.value = true;
try {
customer.value = await getCustomer(customerId.value);
// 操作日志
const res = await getOperateLogPage({
bizType: BizTypeEnum.CRM_CUSTOMER,
bizId: customerId.value,
});
logList.value = res.list;
} finally {
loading.value = false;
}
}
/** 返回列表页 */
function handleBack() {
tabs.closeCurrentTab();
router.push({ name: 'CrmCustomer' });
}
/** 编辑客户 */
function handleEdit() {
formModalApi.setData({ id: customerId.value }).open();
}
/** 转移线索 */
function handleTransfer() {
transferModalApi.setData({ id: customerId.value }).open();
}
/** 锁定客户 */
function handleLock(lockStatus: boolean): Promise<boolean | undefined> {
return new Promise((resolve, reject) => {
confirm({
content: `确定锁定客户【${customer.value.name}】吗?`,
})
.then(async () => {
// 锁定客户
await lockCustomer(customerId.value, lockStatus);
// 提示并返回成功
ElMessage.success(lockStatus ? '锁定客户成功' : '解锁客户成功');
resolve(true);
})
.catch(() => {
reject(new Error('取消操作'));
});
});
}
/** 领取客户 */
function handleReceive(): Promise<boolean | undefined> {
return new Promise((resolve, reject) => {
confirm({
content: `确定领取客户【${customer.value.name}】吗?`,
})
.then(async () => {
// 领取客户
await receiveCustomer([customerId.value]);
// 提示并返回成功
ElMessage.success('领取客户成功');
resolve(true);
})
.catch(() => {
reject(new Error('取消操作'));
});
});
}
/** 分配客户 */
function handleDistributeForm() {
distributeModalApi.setData({ id: customerId.value }).open();
}
/** 客户放入公海 */
function handlePutPool(): Promise<boolean | undefined> {
return new Promise((resolve, reject) => {
confirm({
content: `确定将客户【${customer.value.name}】放入公海吗?`,
})
.then(async () => {
// 放入公海
await putCustomerPool(customerId.value);
// 提示并返回成功
ElMessage.success('放入公海成功');
resolve(true);
})
.catch(() => {
reject(new Error('取消操作'));
});
});
}
/** 更新成交状态操作 */
async function handleUpdateDealStatus(): Promise<boolean | undefined> {
return new Promise((resolve, reject) => {
const dealStatus = !customer.value.dealStatus;
confirm({
content: `确定更新成交状态为【${dealStatus ? '已成交' : '未成交'}】吗?`,
})
.then(async () => {
// 更新成交状态
await updateCustomerDealStatus(customerId.value, dealStatus);
// 提示并返回成功
ElMessage.success('更新成交状态成功');
resolve(true);
})
.catch(() => {
reject(new Error('取消操作'));
});
});
}
/** 加载数据 */
onMounted(() => {
customerId.value = Number(route.params.id);
loadCustomerDetail();
});
</script>
<template>
<Page auto-content-height :title="customer?.name" :loading="loading">
<FormModal @success="loadCustomerDetail" />
<TransferModal @success="loadCustomerDetail" />
<DistributeModal @success="loadCustomerDetail" />
<template #extra>
<TableAction
:actions="[
{
label: '返回',
type: 'default',
icon: 'lucide:arrow-left',
onClick: handleBack,
},
{
label: $t('ui.actionTitle.edit'),
type: 'primary',
icon: ACTION_ICON.EDIT,
auth: ['crm:customer:update'],
ifShow: permissionListRef?.validateWrite,
onClick: handleEdit,
},
{
label: '转移',
type: 'primary',
ifShow: permissionListRef?.validateOwnerUser,
onClick: handleTransfer,
},
{
label: '更改成交状态',
type: 'default',
ifShow: permissionListRef?.validateWrite,
onClick: handleUpdateDealStatus,
},
{
label: '锁定',
type: 'default',
ifShow:
!customer.lockStatus && permissionListRef?.validateOwnerUser,
onClick: handleLock.bind(null, true),
},
{
label: '解锁',
type: 'default',
ifShow: customer.lockStatus && permissionListRef?.validateOwnerUser,
onClick: handleLock.bind(null, false),
},
{
label: '领取',
type: 'primary',
ifShow: !customer.ownerUserId,
onClick: handleReceive,
},
{
label: '分配',
type: 'default',
ifShow: !customer.ownerUserId,
onClick: handleDistributeForm,
},
{
label: '放入公海',
type: 'default',
ifShow:
!!customer.ownerUserId && permissionListRef?.validateOwnerUser,
onClick: handlePutPool,
},
]"
/>
</template>
<ElCard class="min-h-[10%]">
<Descriptions :data="customer" />
</ElCard>
<ElCard class="mt-4 min-h-[60%]">
<ElTabs v-model:model-value="activeTabName">
<ElTabPane label="跟进记录" name="1">
<FollowUp :biz-id="customerId" :biz-type="BizTypeEnum.CRM_CUSTOMER" />
</ElTabPane>
<ElTabPane label="基本信息" name="2">
<Info :customer="customer" />
</ElTabPane>
<ElTabPane label="联系人" name="3">
<ContactDetailsList
:biz-id="customerId"
:biz-type="BizTypeEnum.CRM_CUSTOMER"
:customer-id="customerId"
/>
</ElTabPane>
<ElTabPane label="团队成员" name="4">
<PermissionList
ref="permissionListRef"
:biz-id="customerId"
:biz-type="BizTypeEnum.CRM_CUSTOMER"
:show-action="true"
@quit-team="handleBack"
/>
</ElTabPane>
<ElTabPane label="商机" name="5">
<BusinessDetailsList
:biz-id="customerId"
:biz-type="BizTypeEnum.CRM_CUSTOMER"
:customer-id="customerId"
/>
</ElTabPane>
<ElTabPane label="合同" name="6">
<ContractDetailsList
:biz-id="customerId"
:biz-type="BizTypeEnum.CRM_CUSTOMER"
/>
</ElTabPane>
<ElTabPane label="回款" name="7">
<ReceivablePlanDetailsList :customer-id="customerId" />
<ReceivableDetailsList :customer-id="customerId" />
</ElTabPane>
<ElTabPane label="操作日志" name="8">
<OperateLog :log-list="logList" />
</ElTabPane>
</ElTabs>
</ElCard>
</Page>
</template>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { distributeCustomer } from '#/api/crm/customer';
import { $t } from '#/locales';
import { useDistributeFormSchema } from '../data';
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useDistributeFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = await formApi.getValues();
try {
await distributeCustomer([data.id], data.ownerUserId);
// 关闭并提示
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
// 加载数据
const data = modalApi.getData();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
await formApi.setValues(data);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal title="分配客户" class="w-2/5">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import type { CrmCustomerApi } from '#/api/crm/customer';
import { ElDivider } from 'element-plus';
import { useDescription } from '#/components/description';
import { useFollowUpDetailSchema } from '#/views/crm/followup/data';
import { useDetailBaseSchema } from '../data';
defineProps<{
customer: CrmCustomerApi.Customer; // 客户信息
}>();
const [BaseDescriptions] = useDescription({
title: '基本信息',
border: false,
column: 4,
schema: useDetailBaseSchema(),
});
const [SystemDescriptions] = useDescription({
title: '系统信息',
border: false,
column: 3,
schema: useFollowUpDetailSchema(),
});
</script>
<template>
<div>
<BaseDescriptions :data="customer" />
<ElDivider />
<SystemDescriptions :data="customer" />
</div>
</template>

View File

@@ -0,0 +1,217 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerApi } from '#/api/crm/customer';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import {
ElButton,
ElLoading,
ElMessage,
ElTabPane,
ElTabs,
} from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteCustomer,
exportCustomer,
getCustomerPage,
} from '#/api/crm/customer';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
const { push } = useRouter();
const sceneType = ref('1');
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
connectedComponent: ImportForm,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 处理场景类型的切换 */
function handleChangeSceneType(key: number | string) {
sceneType.value = key.toString();
gridApi.query();
}
/** 导入客户 */
function handleImport() {
importModalApi.open();
}
/** 导出表格 */
async function handleExport() {
const formValues = await gridApi.formApi.getValues();
const data = await exportCustomer({
sceneType: sceneType.value,
...formValues,
});
downloadFileFromBlobPart({ fileName: '客户.xls', source: data });
}
/** 创建客户 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑客户 */
function handleEdit(row: CrmCustomerApi.Customer) {
formModalApi.setData(row).open();
}
/** 删除客户 */
async function handleDelete(row: CrmCustomerApi.Customer) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
});
try {
await deleteCustomer(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 查看客户详情 */
function handleDetail(row: CrmCustomerApi.Customer) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCustomerPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
sceneType: sceneType.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmCustomerApi.Customer>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【客户】客户管理"
url="https://doc.iocoder.cn/crm/customer/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal @success="handleRefresh" />
<ImportModal @success="handleRefresh" />
<Grid>
<template #toolbar-actions>
<ElTabs
class="w-full"
@tab-change="handleChangeSceneType"
v-model:model-value="sceneType"
>
<ElTabPane label="我负责的" name="1" />
<ElTabPane label="我参与的" name="2" />
<ElTabPane label="下属负责的" name="3" />
</ElTabs>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['客户']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:customer:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.import'),
type: 'primary',
icon: ACTION_ICON.UPLOAD,
auth: ['crm:customer:import'],
onClick: handleImport,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['crm:customer:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['crm:customer:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:customer:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,156 @@
import type { VbenFormSchema } from '@vben/common-ui';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { handleTree } from '@vben/utils';
import { LimitConfType } from '#/api/crm/customer/limitConfig';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
/** 新增/修改的表单 */
export function useFormSchema(confType: LimitConfType): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'type',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'userIds',
label: '规则适用人群',
component: 'ApiSelect',
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
mode: 'multiple',
allowClear: true,
placeholder: '请选择规则适用人群',
},
},
{
fieldName: 'deptIds',
label: '规则适用部门',
component: 'ApiTreeSelect',
componentProps: {
api: async () => {
const data = await getSimpleDeptList();
return handleTree(data);
},
multiple: true,
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择规则适用部门',
treeDefaultExpandAll: true,
},
},
{
fieldName: 'maxCount',
label:
confType === LimitConfType.CUSTOMER_QUANTITY_LIMIT
? '拥有客户数上限'
: '锁定客户数上限',
component: 'InputNumber',
componentProps: {
placeholder: `请输入${
LimitConfType.CUSTOMER_QUANTITY_LIMIT === confType
? '拥有客户数上限'
: '锁定客户数上限'
}`,
controlsPosition: 'right',
class: '!w-full',
},
rules: 'required',
},
{
fieldName: 'dealCountEnabled',
label: '成交客户是否占用拥有客户数',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
},
dependencies: {
triggerFields: [''],
show: () => confType === LimitConfType.CUSTOMER_QUANTITY_LIMIT,
},
defaultValue: false,
},
];
}
/** 列表的字段 */
export function useGridColumns(
confType: LimitConfType,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
fixed: 'left',
},
{
field: 'users',
title: '规则适用人群',
formatter: ({ cellValue }) => {
return cellValue
.map((user: any) => {
return user.nickname;
})
.join(',');
},
},
{
field: 'depts',
title: '规则适用部门',
formatter: ({ cellValue }) => {
return cellValue
.map((dept: any) => {
return dept.name;
})
.join(',');
},
},
{
field: 'maxCount',
title:
confType === LimitConfType.CUSTOMER_QUANTITY_LIMIT
? '拥有客户数上限'
: '锁定客户数上限',
},
{
field: 'dealCountEnabled',
title: '成交客户是否占用拥有客户数',
visible: confType === LimitConfType.CUSTOMER_QUANTITY_LIMIT,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,172 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerLimitConfigApi } from '#/api/crm/customer/limitConfig';
import { ref } from 'vue';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage, ElTabPane, ElTabs } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteCustomerLimitConfig,
getCustomerLimitConfigPage,
LimitConfType,
} from '#/api/crm/customer/limitConfig';
import { $t } from '#/locales';
import { useGridColumns } from './data';
import Form from './modules/form.vue';
const configType = ref(LimitConfType.CUSTOMER_QUANTITY_LIMIT);
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 处理配置类型的切换 */
function handleChangeConfigType(key: number | string) {
configType.value = key as LimitConfType;
gridApi.setGridOptions({
columns: useGridColumns(configType.value),
});
handleRefresh();
}
/** 创建规则 */
function handleCreate(type: LimitConfType) {
formModalApi.setData({ type }).open();
}
/** 编辑规则 */
function handleEdit(
row: CrmCustomerLimitConfigApi.CustomerLimitConfig,
type: LimitConfType,
) {
formModalApi.setData({ id: row.id, type }).open();
}
/** 删除规则 */
async function handleDelete(
row: CrmCustomerLimitConfigApi.CustomerLimitConfig,
) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.id]),
});
try {
await deleteCustomerLimitConfig(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.id]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(configType.value),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCustomerLimitConfigPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
type: configType.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmCustomerLimitConfigApi.CustomerLimitConfig>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【客户】客户管理、公海客户"
url="https://doc.iocoder.cn/crm/customer/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal @success="handleRefresh" />
<Grid>
<template #toolbar-actions>
<ElTabs
class="w-full"
@tab-change="handleChangeConfigType"
v-model:model-value="configType"
>
<ElTabPane
label="拥有客户数限制"
:name="LimitConfType.CUSTOMER_QUANTITY_LIMIT"
/>
<ElTabPane
label="锁定客户数限制"
:name="LimitConfType.CUSTOMER_LOCK_LIMIT"
/>
</ElTabs>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['规则']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:customer-limit-config:create'],
onClick: handleCreate.bind(null, configType),
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['crm:customer-limit-config:update'],
onClick: handleEdit.bind(null, row, configType),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:customer-limit-config:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.id]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,100 @@
<script lang="ts" setup>
import type { CrmCustomerLimitConfigApi } from '#/api/crm/customer/limitConfig';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createCustomerLimitConfig,
getCustomerLimitConfig,
LimitConfType,
updateCustomerLimitConfig,
} from '#/api/crm/customer/limitConfig';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmCustomerLimitConfigApi.CustomerLimitConfig>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['规则'])
: $t('ui.actionTitle.create', ['规则']);
});
const confType = ref<LimitConfType>(LimitConfType.CUSTOMER_LOCK_LIMIT);
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 200,
},
layout: 'horizontal',
schema: useFormSchema(confType.value),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data =
(await formApi.getValues()) as CrmCustomerLimitConfigApi.CustomerLimitConfig;
try {
await (formData.value?.id
? updateCustomerLimitConfig(data)
: createCustomerLimitConfig(data));
// 关闭并提示
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
let data =
modalApi.getData<CrmCustomerLimitConfigApi.CustomerLimitConfig>();
if (!data) {
return;
}
if (data.type) {
confType.value = data.type as LimitConfType;
}
formApi.setState({ schema: useFormSchema(confType.value) });
modalApi.lock();
try {
if (data.id) {
data = await getCustomerLimitConfig(data.id);
}
formData.value = data;
// 设置到 values
await formApi.setValues(data);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-2/5">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { CrmCustomerApi } from '#/api/crm/customer';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import {
createCustomer,
getCustomer,
updateCustomer,
} from '#/api/crm/customer';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmCustomerApi.Customer>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['客户'])
: $t('ui.actionTitle.create', ['客户']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
wrapperClass: 'grid-cols-2',
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as CrmCustomerApi.Customer;
try {
await (formData.value?.id ? updateCustomer(data) : createCustomer(data));
// 关闭并提示
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<CrmCustomerApi.Customer>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getCustomer(data.id);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-2/5">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,86 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { ElButton, ElMessage, ElUpload } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { importCustomer, importCustomerTemplate } from '#/api/crm/customer';
import { $t } from '#/locales';
import { useImportFormSchema } from '../data';
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
commonConfig: {
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useImportFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = await formApi.getValues();
try {
await importCustomer({
ownerUserId: data.ownerUserId,
file: data.file,
updateSupport: data.updateSupport,
});
// 关闭并提示
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
});
/** 文件改变时 */
function handleChange(file: any) {
if (file.raw) {
formApi.setFieldValue('file', file.raw);
}
}
/** 下载模版 */
async function handleDownload() {
const data = await importCustomerTemplate();
downloadFileFromBlobPart({ fileName: '客户导入模板.xls', source: data });
}
</script>
<template>
<Modal title="客户导入" class="w-1/3">
<Form class="mx-4">
<template #file>
<div class="w-full">
<ElUpload
:limit="1"
accept=".xls,.xlsx"
:on-change="handleChange"
:auto-upload="false"
>
<ElButton type="primary"> 选择 Excel 文件 </ElButton>
</ElUpload>
</div>
</template>
</Form>
<template #prepend-footer>
<div class="flex flex-auto items-center">
<ElButton @click="handleDownload"> 下载导入模板 </ElButton>
</div>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,161 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '客户名称',
component: 'Input',
componentProps: {
placeholder: '请输入客户名称',
allowClear: true,
},
},
{
fieldName: 'mobile',
label: '手机',
component: 'Input',
componentProps: {
placeholder: '请输入手机',
allowClear: true,
},
},
{
fieldName: 'industryId',
label: '所属行业',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_INDUSTRY, 'number'),
placeholder: '请选择所属行业',
allowClear: true,
},
},
{
fieldName: 'level',
label: '客户级别',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_LEVEL, 'number'),
placeholder: '请选择客户级别',
allowClear: true,
},
},
{
fieldName: 'source',
label: '客户来源',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_CUSTOMER_SOURCE, 'number'),
placeholder: '请选择客户来源',
allowClear: true,
},
},
];
}
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
title: '客户名称',
field: 'name',
minWidth: 160,
fixed: 'left',
slots: { default: 'name' },
},
{
title: '客户来源',
field: 'source',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_SOURCE },
},
},
{
title: '手机',
field: 'mobile',
minWidth: 120,
},
{
title: '电话',
field: 'telephone',
minWidth: 120,
},
{
title: '邮箱',
field: 'email',
minWidth: 140,
},
{
title: '客户级别',
field: 'level',
minWidth: 135,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_LEVEL },
},
},
{
title: '客户行业',
field: 'industryId',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_CUSTOMER_INDUSTRY },
},
},
{
title: '下次联系时间',
field: 'contactNextTime',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '备注',
field: 'remark',
minWidth: 200,
},
{
title: '成交状态',
field: 'dealStatus',
minWidth: 80,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
title: '最后跟进时间',
field: 'contactLastTime',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '最后跟进记录',
field: 'contactLastContent',
minWidth: 200,
},
{
title: '更新时间',
field: 'updateTime',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '创建时间',
field: 'createTime',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '创建人',
field: 'creatorName',
minWidth: 100,
},
];
}

View File

@@ -0,0 +1,97 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerApi } from '#/api/crm/customer';
import { useRouter } from 'vue-router';
import { DocAlert, Page } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { ElButton } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { exportCustomer, getCustomerPage } from '#/api/crm/customer';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
const { push } = useRouter();
/** 导出表格 */
async function handleExport() {
const data = await exportCustomer(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '客户公海.xls', source: data });
}
/** 查看客户详情 */
function handleDetail(row: CrmCustomerApi.Customer) {
push({ name: 'CrmCustomerDetail', params: { id: row.id } });
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCustomerPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
pool: true,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmCustomerApi.Customer>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【客户】客户管理、公海客户"
url="https://doc.iocoder.cn/crm/customer/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<Grid>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['crm:customer:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #name="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.name }}
</ElButton>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,83 @@
import type { VbenFormSchema } from '#/adapter/form';
import { z } from '#/adapter/form';
export const schema: VbenFormSchema[] = [
{
component: 'RadioGroup',
fieldName: 'enabled',
label: '客户公海规则设置',
componentProps: {
options: [
{ label: '开启', value: true },
{ label: '关闭', value: false },
],
},
},
{
component: 'Input',
fieldName: 'contactExpireDays',
componentProps: {
placeholder: '请输入天数',
class: '!w-full',
},
renderComponentContent: () => ({
append: () => '天不跟进或',
}),
rules: z.coerce.number().int().min(0, '天数不能小于 0'),
dependencies: {
triggerFields: ['enabled'],
show: (value) => value.enabled,
},
},
{
component: 'Input',
fieldName: 'dealExpireDays',
componentProps: {
placeholder: '请输入天数',
class: '!w-full',
},
renderComponentContent: () => ({
prepend: () => '或',
append: () => '天未成交',
}),
rules: z.coerce.number().int().min(0, '天数不能小于 0'),
dependencies: {
triggerFields: ['enabled'],
show: (value) => value.enabled,
},
},
{
component: 'RadioGroup',
fieldName: 'notifyEnabled',
label: '提前提醒设置',
componentProps: {
options: [
{ label: '开启', value: true },
{ label: '关闭', value: false },
],
},
dependencies: {
triggerFields: ['enabled'],
show: (value) => value.enabled,
},
defaultValue: false,
},
{
component: 'Input',
fieldName: 'notifyDays',
componentProps: {
placeholder: '请输入天数',
class: '!w-full',
},
renderComponentContent: () => ({
prepend: () => '提前',
append: () => '天提醒',
}),
rules: z.coerce.number().int().min(0, '天数不能小于 0'),
dependencies: {
triggerFields: ['notifyEnabled'],
show: (value) => value.enabled && value.notifyEnabled,
},
},
];

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import type { CrmCustomerPoolConfigApi } from '#/api/crm/customer/poolConfig';
import { onMounted } from 'vue';
import { Page } from '@vben/common-ui';
import { ElCard, ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
getCustomerPoolConfig,
saveCustomerPoolConfig,
} from '#/api/crm/customer/poolConfig';
import { $t } from '#/locales';
import { schema } from './data';
const [Form, formApi] = useVbenForm({
commonConfig: {
labelClass: 'w-100',
},
layout: 'horizontal',
schema,
handleSubmit,
});
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
// 提交表单
const data =
(await formApi.getValues()) as CrmCustomerPoolConfigApi.CustomerPoolConfig;
if (!data.enabled) {
data.contactExpireDays = undefined;
data.dealExpireDays = undefined;
data.notifyEnabled = false;
}
if (!data.notifyEnabled) {
data.notifyDays = undefined;
}
await saveCustomerPoolConfig(data);
// 关闭并提示
await formApi.setValues(data);
ElMessage.success($t('ui.actionMessage.operationSuccess'));
}
/** 获取配置 */
async function getConfigInfo() {
const res = await getCustomerPoolConfig();
await formApi.setValues(res);
}
/** 初始化 */
onMounted(() => {
getConfigInfo();
});
</script>
<template>
<Page auto-content-height>
<ElCard title="客户公海规则设置">
<Form class="w-1/4" />
</ElCard>
</Page>
</template>

View File

@@ -50,6 +50,7 @@ export function useFormSchema(
showTime: true,
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'x',
placeholder: '请选择下次联系时间',
class: '!w-full',
},
rules: 'required',

View File

@@ -31,7 +31,9 @@ export function useFormSchema(): VbenFormSchema[] {
} as CrmProductCategoryApi.ProductCategory);
return handleTree(data);
},
fieldNames: { label: 'name', value: 'id', children: 'children' },
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择上级分类',
showSearch: true,
treeDefaultExpandAll: true,
@@ -85,7 +87,7 @@ export function useGridColumns(): VxeTableGridOptions<CrmProductCategoryApi.Prod
{
field: 'actions',
title: '操作',
width: 200,
width: 250,
fixed: 'right',
slots: {
default: 'actions',

View File

@@ -68,6 +68,8 @@ const [Modal, modalApi] = useVbenModal({
// 加载数据
let data = modalApi.getData<CrmProductCategoryApi.ProductCategory>();
if (!data || !data.id) {
// 设置上级
await formApi.setValues(data);
return;
}
modalApi.lock();

View File

@@ -126,10 +126,8 @@ watch(
},
);
/** 产品下拉选项 */
const productOptions = ref<CrmProductApi.Product[]>([]);
/** 初始化 */
const productOptions = ref<CrmProductApi.Product[]>([]); // 产品下拉选项
onMounted(async () => {
productOptions.value = await getProductSimpleList();
});

View File

@@ -27,11 +27,11 @@ const loading = ref(false); // 加载中
const productId = ref(0); // 产品编号
const product = ref<CrmProductApi.Product>({} as CrmProductApi.Product); // 产品详情
const logList = ref<SystemOperateLogApi.OperateLog[]>([]); // 操作日志
const activeTabName = ref('1'); // 选中 Tab 名
const [Descriptions] = useDescription({
bordered: false,
border: false,
column: 4,
class: 'mx-4',
schema: useDetailSchema(),
});
@@ -76,7 +76,7 @@ onMounted(() => {
<Descriptions :data="product" />
</ElCard>
<ElCard class="mt-4 min-h-[60%]">
<ElTabs>
<ElTabs v-model:model-value="activeTabName">
<ElTabPane label="详细资料" name="1">
<Info :product="product" />
</ElTabPane>

View File

@@ -6,20 +6,18 @@ import { useDescription } from '#/components/description';
import { useDetailBaseSchema } from '../data';
defineProps<{
product: CrmProductApi.Product; // 产品信息
product: CrmProductApi.Product;
}>();
const [ProductDescriptions] = useDescription({
title: '基本信息',
bordered: false,
border: false,
column: 4,
class: 'mx-4',
schema: useDetailBaseSchema(),
});
</script>
<template>
<div class="p-4">
<div>
<ProductDescriptions :data="product" />
</div>
</template>

View File

@@ -0,0 +1,73 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
/** 详情列表的字段 */
export function useDetailListColumns(): VxeTableGridOptions['columns'] {
return [
{
title: '回款编号',
field: 'no',
minWidth: 150,
fixed: 'left',
},
{
title: '客户名称',
field: 'customerName',
minWidth: 150,
},
{
title: '合同编号',
field: 'contract.no',
minWidth: 150,
},
{
title: '回款日期',
field: 'returnTime',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '回款金额(元)',
field: 'price',
minWidth: 150,
formatter: 'formatAmount2',
},
{
title: '回款方式',
field: 'returnType',
minWidth: 150,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE },
},
},
{
title: '负责人',
field: 'ownerUserName',
minWidth: 150,
},
{
title: '备注',
field: 'remark',
minWidth: 150,
},
{
title: '回款状态',
field: 'auditStatus',
minWidth: 100,
fixed: 'right',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
},
},
{
title: '操作',
field: 'actions',
width: 130,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,144 @@
<!-- 回款列表用于客户合同详情中展示它们关联的回款列表 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmReceivableApi } from '#/api/crm/receivable';
import { useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteReceivable,
getReceivablePageByCustomer,
} from '#/api/crm/receivable';
import { $t } from '#/locales';
import Form from '../modules/form.vue';
import { useDetailListColumns } from './data';
const props = defineProps<{
contractId?: number; // 合同编号
customerId?: number; // 客户编号
}>();
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建回款 */
function handleCreate() {
formModalApi
.setData({
contractId: props.contractId,
customerId: props.customerId,
})
.open();
}
/** 编辑回款 */
function handleEdit(row: CrmReceivableApi.Receivable) {
formModalApi.setData({ receivable: row }).open();
}
/** 删除回款 */
async function handleDelete(row: CrmReceivableApi.Receivable) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.no]),
});
try {
await deleteReceivable(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.no]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useDetailListColumns(),
height: 500,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }) => {
const queryParams: CrmReceivableApi.ReceivablePageParam = {
pageNo: page.currentPage,
pageSize: page.pageSize,
};
if (props.customerId && !props.contractId) {
queryParams.customerId = props.customerId;
} else if (props.customerId && props.contractId) {
// 如果是合同的话客户编号也需要带上因为权限基于客户
queryParams.customerId = props.customerId;
queryParams.contractId = props.contractId;
}
return await getReceivablePageByCustomer(queryParams);
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmReceivableApi.Receivable>,
});
</script>
<template>
<div>
<FormModal @success="handleRefresh" />
<Grid>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['回款']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:receivable:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['crm:receivable:update'],
onClick: handleEdit.bind(null, row),
ifShow: row.auditStatus === 0,
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:receivable:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.no]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as ReceivableDetailsList } from './detail-list.vue';

View File

@@ -0,0 +1,301 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { useUserStore } from '@vben/stores';
import { getContractSimpleList } from '#/api/crm/contract';
import { getCustomerSimpleList } from '#/api/crm/customer';
import {
getReceivablePlan,
getReceivablePlanSimpleList,
} from '#/api/crm/receivable/plan';
import { getSimpleUserList } from '#/api/system/user';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
const userStore = useUserStore();
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'no',
label: '回款编号',
component: 'Input',
componentProps: {
placeholder: '保存时自动生成',
disabled: true,
},
},
{
fieldName: 'ownerUserId',
label: '负责人',
component: 'ApiSelect',
rules: 'required',
dependencies: {
triggerFields: ['id'],
disabled: (values) => values.id,
},
componentProps: {
api: getSimpleUserList,
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择负责人',
allowClear: true,
},
defaultValue: userStore.userInfo?.id,
},
{
fieldName: 'customerId',
label: '客户名称',
component: 'ApiSelect',
rules: 'required',
componentProps: {
api: getCustomerSimpleList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择客户',
},
dependencies: {
triggerFields: ['id'],
disabled: (values) => values.id,
},
},
{
fieldName: 'contractId',
label: '合同名称',
component: 'Select',
rules: 'required',
dependencies: {
triggerFields: ['customerId'],
disabled: (values) => !values.customerId || values.id,
async componentProps(values) {
if (values.customerId) {
if (!values.id) {
// 特殊:只有在【新增】时,才清空合同编号
values.contractId = undefined;
}
const contracts = await getContractSimpleList(values.customerId);
return {
options: contracts.map((item) => ({
label: item.name,
value: item.id,
})),
placeholder: '请选择合同',
} as any;
}
},
},
},
{
fieldName: 'planId',
label: '回款期数',
component: 'Select',
rules: 'required',
dependencies: {
triggerFields: ['contractId'],
disabled: (values) => !values.contractId,
async componentProps(values) {
if (values.contractId) {
values.planId = undefined;
const plans = await getReceivablePlanSimpleList(
values.customerId,
values.contractId,
);
return {
options: plans.map((item) => ({
label: item.period,
value: item.id,
})),
placeholder: '请选择回款期数',
onChange: async (value: any) => {
const plan = await getReceivablePlan(value);
values.returnTime = plan?.returnTime;
values.price = plan?.price;
values.returnType = plan?.returnType;
},
} as any;
}
},
},
},
{
fieldName: 'returnType',
label: '回款方式',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE, 'number'),
placeholder: '请选择回款方式',
},
},
{
fieldName: 'price',
label: '回款金额',
component: 'InputNumber',
rules: 'required',
componentProps: {
placeholder: '请输入回款金额',
min: 0,
precision: 2,
controlsPosition: 'right',
class: '!w-full',
},
},
{
fieldName: 'returnTime',
label: '回款日期',
component: 'DatePicker',
rules: 'required',
componentProps: {
placeholder: '请选择回款日期',
valueFormat: 'x',
format: 'YYYY-MM-DD',
class: '!w-full',
},
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
rows: 4,
},
formItemClass: 'md:col-span-2',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'no',
label: '回款编号',
component: 'Input',
componentProps: {
placeholder: '请输入回款编号',
allowClear: true,
},
},
{
fieldName: 'customerId',
label: '客户',
component: 'ApiSelect',
componentProps: {
api: getCustomerSimpleList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择客户',
allowClear: true,
},
},
];
}
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
title: '回款编号',
field: 'no',
minWidth: 160,
fixed: 'left',
slots: { default: 'no' },
},
{
title: '客户名称',
field: 'customerName',
minWidth: 150,
slots: { default: 'customerName' },
},
{
title: '合同编号',
field: 'contract',
minWidth: 160,
slots: { default: 'contractNo' },
},
{
title: '回款日期',
field: 'returnTime',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '回款金额(元)',
field: 'price',
minWidth: 150,
formatter: 'formatAmount2',
},
{
title: '回款方式',
field: 'returnType',
minWidth: 150,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE },
},
},
{
title: '备注',
field: 'remark',
minWidth: 150,
},
{
title: '合同金额(元)',
field: 'contract.totalPrice',
minWidth: 150,
formatter: 'formatAmount2',
},
{
title: '负责人',
field: 'ownerUserName',
minWidth: 150,
},
{
title: '所属部门',
field: 'ownerUserDeptName',
minWidth: 150,
},
{
title: '更新时间',
field: 'updateTime',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '创建时间',
field: 'createTime',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '创建人',
field: 'creatorName',
minWidth: 150,
},
{
title: '回款状态',
field: 'auditStatus',
minWidth: 100,
fixed: 'right',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
},
},
{
title: '操作',
field: 'actions',
minWidth: 200,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,105 @@
import type { DescriptionItemSchema } from '#/components/description';
import { h } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { erpPriceInputFormatter, formatDateTime } from '@vben/utils';
import { DictTag } from '#/components/dict-tag';
/** 详情页的字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [
{
field: 'customerName',
label: '客户名称',
},
{
field: 'totalPrice',
label: '合同金额(元)',
render: (val, data) =>
erpPriceInputFormatter(val ?? data?.contract?.totalPrice ?? 0),
},
{
field: 'returnTime',
label: '回款日期',
render: (val) => formatDateTime(val) as string,
},
{
field: 'price',
label: '回款金额(元)',
render: (val) => erpPriceInputFormatter(val),
},
{
field: 'ownerUserName',
label: '负责人',
},
];
}
/** 详情页的基础字段 */
export function useDetailBaseSchema(): DescriptionItemSchema[] {
return [
{
field: 'no',
label: '回款编号',
},
{
field: 'customerName',
label: '客户名称',
},
{
field: 'contract',
label: '合同编号',
render: (val, data) =>
val && data?.contract?.no ? data?.contract?.no : '',
},
{
field: 'returnTime',
label: '回款日期',
render: (val) => formatDateTime(val) as string,
},
{
field: 'price',
label: '回款金额',
render: (val) => erpPriceInputFormatter(val),
},
{
field: 'returnType',
label: '回款方式',
render: (val) =>
h(DictTag, {
type: DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE,
value: val,
}),
},
{
field: 'remark',
label: '备注',
},
];
}
/** 系统信息字段 */
export function useDetailSystemSchema(): DescriptionItemSchema[] {
return [
{
field: 'ownerUserName',
label: '负责人',
},
{
field: 'creatorName',
label: '创建人',
},
{
field: 'createTime',
label: '创建时间',
render: (val) => formatDateTime(val) as string,
},
{
field: 'updateTime',
label: '更新时间',
render: (val) => formatDateTime(val) as string,
},
];
}

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import type { CrmReceivableApi } from '#/api/crm/receivable';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { ElCard, ElTabPane, ElTabs } from 'element-plus';
import { getOperateLogPage } from '#/api/crm/operateLog';
import { BizTypeEnum } from '#/api/crm/permission';
import { getReceivable } from '#/api/crm/receivable';
import { useDescription } from '#/components/description';
import { OperateLog } from '#/components/operate-log';
import { ACTION_ICON, TableAction } from '#/components/table-action';
import { $t } from '#/locales';
import { PermissionList } from '#/views/crm/permission';
import ReceivableForm from '../modules/form.vue';
import { useDetailSchema } from './data';
import Info from './modules/info.vue';
const props = defineProps<{ id?: number }>();
const route = useRoute();
const router = useRouter();
const tabs = useTabs();
const loading = ref(false); // 加载中
const receivableId = ref(0); // 回款编号
const receivable = ref<CrmReceivableApi.Receivable>(
{} as CrmReceivableApi.Receivable,
); // 回款详情
const logList = ref<SystemOperateLogApi.OperateLog[]>([]); // 操作日志
const permissionListRef = ref<InstanceType<typeof PermissionList>>(); // 团队成员列表 Ref
const activeTabName = ref('1'); // 选中 Tab 名
const [Descriptions] = useDescription({
border: false,
column: 4,
schema: useDetailSchema(),
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: ReceivableForm,
destroyOnClose: true,
});
/** 加载回款详情 */
async function loadReceivableDetail() {
loading.value = true;
try {
receivable.value = await getReceivable(receivableId.value);
// 操作日志
const res = await getOperateLogPage({
bizType: BizTypeEnum.CRM_RECEIVABLE,
bizId: receivableId.value,
});
logList.value = res.list;
} finally {
loading.value = false;
}
}
/** 返回列表页 */
function handleBack() {
tabs.closeCurrentTab();
router.push({ name: 'CrmReceivable' });
}
/** 编辑收款 */
function handleEdit() {
formModalApi.setData({ receivable: { id: receivableId.value } }).open();
}
/** 加载数据 */
onMounted(() => {
receivableId.value = Number(props.id || route.params.id);
loadReceivableDetail();
});
</script>
<template>
<Page auto-content-height :title="receivable?.no" :loading="loading">
<FormModal @success="loadReceivableDetail" />
<template #extra>
<TableAction
:actions="[
{
label: '返回',
type: 'default',
icon: 'lucide:arrow-left',
onClick: handleBack,
},
{
label: $t('ui.actionTitle.edit'),
type: 'primary',
icon: ACTION_ICON.EDIT,
auth: ['crm:receivable:update'],
ifShow: permissionListRef?.validateWrite,
onClick: handleEdit,
},
]"
/>
</template>
<ElCard class="min-h-[10%]">
<Descriptions :data="receivable" />
</ElCard>
<ElCard class="mt-4 min-h-[60%]">
<ElTabs v-model:model-value="activeTabName">
<ElTabPane label="详细资料" name="1" :lazy="false">
<Info :receivable="receivable" />
</ElTabPane>
<ElTabPane label="操作日志" name="2" :lazy="false">
<OperateLog :log-list="logList" />
</ElTabPane>
<ElTabPane label="团队成员" name="3" :lazy="false">
<PermissionList
ref="permissionListRef"
:biz-id="receivableId"
:biz-type="BizTypeEnum.CRM_RECEIVABLE"
:show-action="true"
@quit-team="handleBack"
/>
</ElTabPane>
</ElTabs>
</ElCard>
</Page>
</template>

View File

@@ -0,0 +1,35 @@
<script lang="ts" setup>
import type { CrmReceivableApi } from '#/api/crm/receivable';
import { ElDivider } from 'element-plus';
import { useDescription } from '#/components/description';
import { useDetailBaseSchema, useDetailSystemSchema } from '../data';
defineProps<{
receivable: CrmReceivableApi.Receivable; // 收款信息
}>();
const [BaseDescriptions] = useDescription({
title: '基本信息',
border: false,
column: 4,
schema: useDetailBaseSchema(),
});
const [SystemDescriptions] = useDescription({
title: '系统信息',
border: false,
column: 3,
schema: useDetailSystemSchema(),
});
</script>
<template>
<div>
<BaseDescriptions :data="receivable" />
<ElDivider />
<SystemDescriptions :data="receivable" />
</div>
</template>

View File

@@ -0,0 +1,263 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmReceivableApi } from '#/api/crm/receivable';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import {
ElButton,
ElLoading,
ElMessage,
ElTabPane,
ElTabs,
} from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteReceivable,
exportReceivable,
getReceivablePage,
submitReceivable,
} from '#/api/crm/receivable';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const { push } = useRouter();
const sceneType = ref('1');
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 处理场景类型的切换 */
function handleChangeSceneType(key: number | string) {
sceneType.value = key.toString();
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const formValues = await gridApi.formApi.getValues();
const data = await exportReceivable({
sceneType: sceneType.value,
...formValues,
});
downloadFileFromBlobPart({ fileName: '回款.xls', source: data });
}
/** 创建回款 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑回款 */
function handleEdit(row: CrmReceivableApi.Receivable) {
formModalApi.setData({ receivable: row }).open();
}
/** 删除回款 */
async function handleDelete(row: CrmReceivableApi.Receivable) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.no]),
});
try {
await deleteReceivable(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.no]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 提交审核 */
async function handleSubmit(row: CrmReceivableApi.Receivable) {
const loadingInstance = ElLoading.service({
text: '提交审核中...',
});
try {
await submitReceivable(row.id!);
ElMessage.success('提交审核成功');
handleRefresh();
} finally {
loadingInstance.close();
}
}
/** 查看回款详情 */
function handleDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmReceivableDetail', params: { id: row.id } });
}
/** 查看客户详情 */
function handleCustomerDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmCustomerDetail', params: { id: row.customerId } });
}
/** 查看合同详情 */
function handleContractDetail(row: CrmReceivableApi.Receivable) {
push({ name: 'CrmContractDetail', params: { id: row.contractId } });
}
/** 查看审批详情 */
function handleProcessDetail(row: CrmReceivableApi.Receivable) {
push({
name: 'BpmProcessInstanceDetail',
query: { id: row.processInstanceId },
});
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getReceivablePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
sceneType: sceneType.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<CrmReceivableApi.Receivable>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【回款】回款管理、回款计划"
url="https://doc.iocoder.cn/crm/receivable/"
/>
<DocAlert
title="【通用】数据权限"
url="https://doc.iocoder.cn/crm/permission/"
/>
</template>
<FormModal @success="handleRefresh" />
<Grid>
<template #toolbar-actions>
<ElTabs
class="w-full"
@tab-change="handleChangeSceneType"
v-model:model-value="sceneType"
>
<ElTabPane label="我负责的" name="1" />
<ElTabPane label="我参与的" name="2" />
<ElTabPane label="下属负责的" name="3" />
</ElTabs>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['回款']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['crm:receivable:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['crm:receivable:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #no="{ row }">
<ElButton type="primary" link @click="handleDetail(row)">
{{ row.no }}
</ElButton>
</template>
<template #customerName="{ row }">
<ElButton type="primary" link @click="handleCustomerDetail(row)">
{{ row.customerName }}
</ElButton>
</template>
<template #contractNo="{ row }">
<ElButton
v-if="row.contract"
type="primary"
link
@click="handleContractDetail(row)"
>
{{ row.contract.no }}
</ElButton>
<span v-else>--</span>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['crm:receivable:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '提交审核',
type: 'primary',
link: true,
auth: ['crm:receivable:update'],
onClick: handleSubmit.bind(null, row),
ifShow: row.auditStatus === 0,
},
{
label: '查看审批',
type: 'primary',
link: true,
auth: ['crm:receivable:update'],
onClick: handleProcessDetail.bind(null, row),
ifShow: row.auditStatus !== 0,
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['crm:receivable:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.no]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,102 @@
<script lang="ts" setup>
import type { CrmReceivableApi } from '#/api/crm/receivable';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import {
createReceivable,
getReceivable,
updateReceivable,
} from '#/api/crm/receivable';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<CrmReceivableApi.Receivable>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['回款'])
: $t('ui.actionTitle.create', ['回款']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
},
wrapperClass: 'grid-cols-2',
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as CrmReceivableApi.Receivable;
try {
await (formData.value?.id
? updateReceivable(data)
: createReceivable(data));
// 关闭并提示
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData();
if (!data) {
return;
}
const { receivable, plan } = data;
modalApi.lock();
try {
if (receivable) {
formData.value = await getReceivable(receivable.id!);
} else if (plan) {
formData.value = plan.id
? {
planId: plan.id,
price: plan.price,
returnType: plan.returnType,
customerId: plan.customerId,
contractId: plan.contractId,
}
: ({
customerId: plan.customerId,
contractId: plan.contractId,
} as any);
}
// 设置到 values
await formApi.setValues(formData.value as any);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-2/5">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,62 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
/** 详情列表的字段 */
export function useDetailListColumns(): VxeTableGridOptions['columns'] {
return [
{
title: '客户名称',
field: 'customerName',
minWidth: 150,
},
{
title: '合同编号',
field: 'contractNo',
minWidth: 150,
},
{
title: '期数',
field: 'period',
minWidth: 150,
},
{
title: '计划回款(元)',
field: 'price',
minWidth: 150,
formatter: 'formatAmount2',
},
{
title: '计划回款日期',
field: 'returnTime',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '提前几天提醒',
field: 'remindDays',
minWidth: 150,
},
{
title: '提醒日期',
field: 'remindTime',
minWidth: 150,
formatter: 'formatDateTime',
},
{
title: '负责人',
field: 'ownerUserName',
minWidth: 150,
},
{
title: '备注',
field: 'remark',
minWidth: 150,
},
{
title: '操作',
field: 'actions',
width: 240,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

Some files were not shown because too many files have changed in this diff Show More