refactor: followDetail

This commit is contained in:
xinxin.wu
2025-03-18 18:44:43 +08:00
committed by Craftsman
parent 5c75c682d7
commit dd7f5ea386
19 changed files with 383 additions and 286 deletions

View File

@@ -38,3 +38,4 @@ export const BatchAssignOpenSeaCustomerUrl = '/pool/customer/batch-assign'; //
export const AssignOpenSeaCustomerUrl = '/pool/customer/assign'; // 分配公海客户
export const GetOpenSeaOptionsUrl = '/pool/customer/options'; // 获取公海选项
export const DeleteOpenSeaCustomerUrl = '/pool/customer/delete'; // 删除公海客户
export const CancelCustomerFollowPlanUrl = '/customer/follow/plan/cancel'; // 取消客户跟进计划

View File

@@ -5,9 +5,9 @@ export enum FormDesignKeyEnum {
CUSTOMER = 'customer', // 客户
CUSTOMER_OPEN_SEA = 'customerOpenSea', // 公海客户
CONTACT = 'contact', // 联系人
FOLLOW_RECORD_CUSTOMER = 'recordCustomer', // 客户跟进记录
FOLLOW_PLAN_CUSTOMER = 'planCustomer', // 客户跟进计划
BUSINESS = 'business', // 商机
FOLLOW_RECORD_CUSTOMER = 'record', // 客户跟进记录
FOLLOW_PLAN_CUSTOMER = 'plan', // 客户跟进计划
BUSINESS = 'opportunity', // 商机
FOLLOW_RECORD_BUSINESS = 'recordBusiness', // 商机跟进记录
FOLLOW_PLAN_BUSINESS = 'planBusiness', // 商机跟进计划
PRODUCT = 'product', // 产品

View File

@@ -25,3 +25,10 @@ export enum StageResultEnum {
/** 失败 */
FAIL = 'FAIL',
}
export enum OpportunitySearchTypeEnum {
ALL = 'ALL',
SELF = 'SELF',
DEPARTMENT = 'DEPARTMENT',
DEAL = 'DEAL',
}

View File

@@ -10,6 +10,7 @@ import {
BatchDeleteOpenSeaCustomerUrl,
BatchPickOpenSeaCustomerUrl,
BatchTransferCustomerUrl,
CancelCustomerFollowPlanUrl,
DeleteCustomerContactUrl,
DeleteCustomerOpenSeaUrl,
DeleteCustomerUrl,
@@ -154,6 +155,11 @@ export function getCustomerFollowPlanList(data: CustomerFollowPlanTableParams) {
return CDR.post<CommonList<CustomerFollowPlanListItem>>({ url: GetCustomerFollowPlanListUrl, data });
}
// 取消客户跟进计划
export function cancelCustomerFollowPlan(id: string) {
return CDR.get({ url: `${CancelCustomerFollowPlanUrl}/${id}` });
}
// 获取客户跟进计划表单配置
export function getCustomerFollowPlanFormConfig() {
return CDR.get<FormDesignConfigDetailParams>({ url: GetCustomerFollowPlanFormConfigUrl });

View File

@@ -355,6 +355,7 @@
.n-base-selection-popover {
.n-base-selection-tags {
padding: 4px;
height: 26px;
}
.n-base-selection-tag-wrapper {
padding-bottom: 0;

View File

@@ -1,27 +1,26 @@
<template>
<div :class="`crm-follow-detail ${props.noPadding ? '' : 'p-[24px]'} ${props.wrapperClass}`">
<div :class="`crm-follow-detail p-[24px] ${props.wrapperClass}`">
<div class="mb-[16px] flex items-center justify-between">
<div v-if="props.showTitle" class="font-medium text-[var(--text-n1)]">
<div v-if="props.activeType === 'followRecord'" class="font-medium text-[var(--text-n1)]">
{{ t('crmFollowRecord.followRecord') }}
</div>
<CrmTab
v-if="props.type === 'followPlan'"
v-if="props.activeType === 'followPlan'"
v-model:active-tab="activeStatus"
no-content
:tab-list="statusTabList"
type="segment"
@change="() => emit('search')"
@change="() => loadFollowList()"
>
</CrmTab>
<CrmSearchInput
v-if="props.showSearchInput"
v-model:value="followKeyword"
:placeholder="t('common.byKeywordSearch')"
class="!w-[240px]"
@search="() => emit('search')"
@search="(val) => searchData(val)"
/>
</div>
<n-spin :show="props.loading" class="h-full">
<n-spin :show="loading" class="h-full">
<FollowRecord
v-model:data="data"
v-model:keyword="followKeyword"
@@ -29,17 +28,17 @@
:get-description-fun="getDescriptionFun"
key-field="id"
:empty-text="
props.type === 'followPlan' ? t('crmFollowRecord.noFollowPlan') : t('crmFollowRecord.noFollowRecord')
props.activeType === 'followPlan' ? t('crmFollowRecord.noFollowPlan') : t('crmFollowRecord.noFollowRecord')
"
@reach-bottom="() => emit('reachBottom')"
@reach-bottom="handleReachBottom"
>
<template #headerAction="{ item }">
<div class="flex items-center gap-[12px]">
<n-button
v-if="props.type === 'followPlan' && item.status !== CustomerFollowPlanStatusEnum.CANCELLED"
v-if="props.activeType === 'followPlan' && item.status !== CustomerFollowPlanStatusEnum.CANCELLED"
type="primary"
text
@click="cancelPlan(item)"
@click="handleCancelPlan(item)"
>
{{ t('common.cancelPlan') }}
</n-button>
@@ -60,6 +59,12 @@
</template>
</FollowRecord>
</n-spin>
<CrmFormCreateDrawer
v-model:visible="formDrawerVisible"
:form-key="realFormKey"
:source-id="realFollowSourceId"
@saved="() => loadFollowList()"
/>
</div>
</template>
@@ -68,52 +73,50 @@
import dayjs from 'dayjs';
import { CustomerFollowPlanStatusEnum } from '@lib/shared/enums/customerEnum';
import { FormDesignKeyEnum } from '@lib/shared/enums/formDesignEnum';
import type { FollowDetailItem } from '@lib/shared/models/customer';
import type { Description } from '@/components/pure/crm-detail-card/index.vue';
import CrmSearchInput from '@/components/pure/crm-search-input/index.vue';
import CrmTab from '@/components/pure/crm-tab/index.vue';
import CrmFormCreateDrawer from '@/components/business/crm-form-create-drawer/index.vue';
import FollowRecord from './followRecord.vue';
import { useI18n } from '@/hooks/useI18n';
import useFollowApi, { type followEnumType } from './useFollowApi';
const { t } = useI18n();
export type ActiveType = 'followPlan' | 'followRecord';
interface FollowDetailProps {
type: string; // 跟进记录|跟进计划
showSearchInput?: boolean; // 是否显示检索框
showTitle?: boolean; // 是否显示标题
noPadding?: boolean; // 无边距
activeType: 'followRecord' | 'followPlan'; // 跟进记录|跟进计划
followApiKey: followEnumType; // 跟进计划apiKey
virtualScrollHeight?: string; // 虚拟高度
wrapperClass?: string;
loading: boolean;
sourceId: string; // 资源id
}
const props = withDefaults(defineProps<FollowDetailProps>(), {
showSearchInput: true,
noPadding: false,
});
const props = defineProps<FollowDetailProps>();
const emit = defineEmits<{
(e: 'search'): void;
(e: 'handleEdit', item: FollowDetailItem): void;
(e: 'cancelPlan', item: FollowDetailItem): void;
(e: 'reachBottom'): void;
}>();
const formDrawerVisible = ref(false);
const data = defineModel<FollowDetailItem[]>('data', {
required: true,
default: [],
});
const activeStatus = defineModel<string | number>('activeStatus', {
default: '',
});
const innerKeyword = defineModel<string>('keyword', {
default: '',
const {
data,
loading,
handleReachBottom,
searchData,
activeStatus,
loadFollowList,
handleCancelPlan,
followKeyword,
followFormKeyMap,
} = useFollowApi({
type: toRef(props, 'activeType'),
followApiKey: props.followApiKey,
sourceId: toRef(props, 'sourceId'),
});
// 跟进计划状态
@@ -182,26 +185,19 @@
})) || []) as Description[];
}
// 取消计划
function cancelPlan(item: FollowDetailItem) {
emit('cancelPlan', item);
}
// 编辑记录或计划
const realFormKey = ref<FormDesignKeyEnum>(FormDesignKeyEnum.FOLLOW_RECORD_CUSTOMER);
const realFollowSourceId = ref<string | undefined>('');
// 编辑记录
function handleEdit(item: FollowDetailItem) {
emit('handleEdit', item);
realFormKey.value = followFormKeyMap[props.followApiKey as keyof typeof followFormKeyMap][props.activeType];
realFollowSourceId.value = item.id;
formDrawerVisible.value = true;
}
const followKeyword = ref<string>('');
watch(
() => innerKeyword.value,
(val) => {
if (val && !props.showSearchInput) {
followKeyword.value = val;
}
}
);
onBeforeMount(() => {
loadFollowList();
});
</script>
<style lang="less" scoped>

View File

@@ -0,0 +1,180 @@
import { ref } from 'vue';
import { useMessage } from 'naive-ui';
import { CustomerFollowPlanStatusEnum } from '@lib/shared/enums/customerEnum';
import { FormDesignKeyEnum } from '@lib/shared/enums/formDesignEnum';
import type { CommonList } from '@lib/shared/models/common';
import type { FollowDetailItem } from '@lib/shared/models/customer';
import { cancelClueFollowPlan, getClueFollowPlanList, getClueFollowRecordList } from '@/api/modules/clue/index';
import {
cancelCustomerFollowPlan,
getCustomerFollowPlanList,
getCustomerFollowRecordList,
} from '@/api/modules/customer/index';
import { cancelOptFollowPlan, getOptFollowPlanList, getOptFollowRecordList } from '@/api/modules/opportunity';
import { useI18n } from '@/hooks/useI18n';
export type followEnumType =
| typeof FormDesignKeyEnum.CUSTOMER
| typeof FormDesignKeyEnum.BUSINESS
| typeof FormDesignKeyEnum.CLUE;
type FollowApiMapType = Record<
followEnumType,
{
list: {
followRecord: (params: any) => Promise<CommonList<FollowDetailItem>>;
followPlan: (params: any) => Promise<CommonList<FollowDetailItem>>;
};
cancel: {
followPlan: typeof cancelOptFollowPlan;
};
}
>;
const followApiMap: FollowApiMapType = {
[FormDesignKeyEnum.BUSINESS]: {
list: {
followRecord: getOptFollowRecordList,
followPlan: getOptFollowPlanList,
},
cancel: {
followPlan: cancelOptFollowPlan,
},
},
[FormDesignKeyEnum.CUSTOMER]: {
list: {
followRecord: getCustomerFollowRecordList,
followPlan: getCustomerFollowPlanList,
},
cancel: {
followPlan: cancelCustomerFollowPlan,
},
},
[FormDesignKeyEnum.CLUE]: {
list: {
followRecord: getClueFollowRecordList,
followPlan: getClueFollowPlanList,
},
cancel: {
followPlan: cancelClueFollowPlan,
},
},
};
const followFormKeyMap: Record<
followEnumType,
{
followRecord: FormDesignKeyEnum;
followPlan: FormDesignKeyEnum;
}
> = {
[FormDesignKeyEnum.CUSTOMER]: {
followRecord: FormDesignKeyEnum.FOLLOW_RECORD_CUSTOMER, // 客户跟进记录
followPlan: FormDesignKeyEnum.FOLLOW_PLAN_CUSTOMER, // 客户跟进计划
},
[FormDesignKeyEnum.BUSINESS]: {
followRecord: FormDesignKeyEnum.FOLLOW_RECORD_BUSINESS, // 商机跟进记录
followPlan: FormDesignKeyEnum.FOLLOW_PLAN_BUSINESS, // 商机跟进计划
},
[FormDesignKeyEnum.CLUE]: {
followRecord: FormDesignKeyEnum.FOLLOW_RECORD_CLUE, // 线索跟进记录
followPlan: FormDesignKeyEnum.FOLLOW_PLAN_CLUE, // 线索跟进计划
},
};
export default function useFollowApi(followProps: {
followApiKey: (typeof FormDesignKeyEnum)['CUSTOMER' | 'BUSINESS' | 'CLUE'];
type: Ref<'followRecord' | 'followPlan'>;
sourceId: Ref<string>;
}) {
const { t } = useI18n();
const Message = useMessage();
const data = ref<FollowDetailItem[]>([]);
const activeStatus = ref<CustomerFollowPlanStatusEnum>(CustomerFollowPlanStatusEnum.ALL);
const followKeyword = ref<string>('');
const loading = ref(false);
const pageNation = ref({
total: 0,
pageSize: 10,
current: 1,
});
const { type, followApiKey, sourceId } = followProps;
const apis = followApiMap[followApiKey];
async function loadFollowList() {
loading.value = true;
try {
const params = {
sourceId: sourceId.value,
current: pageNation.value.current || 1,
pageSize: pageNation.value.pageSize,
keyword: followKeyword.value,
...(type.value === 'followPlan' && { status: activeStatus.value }),
};
const res = await apis.list[type.value](params);
data.value = res.list;
pageNation.value.total = res.total;
} catch (err) {
// eslint-disable-next-line no-console
console.log(err);
} finally {
loading.value = false;
}
}
// 取消计划
async function handleCancelPlan(item: FollowDetailItem) {
try {
await apis.cancel.followPlan(item.id);
Message.success(t('common.cancelSuccess'));
loadFollowList();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
}
function handleReachBottom() {
pageNation.value.current += 1;
if (pageNation.value.current > Math.ceil(pageNation.value.total / pageNation.value.pageSize)) {
return;
}
loadFollowList();
}
function searchData(keyword: string) {
followKeyword.value = keyword;
loadFollowList();
}
watch(
() => type.value,
(val) => {
if (['followPlan', 'followRecord'].includes(val)) {
loadFollowList();
}
}
);
return {
data,
loading,
handleReachBottom,
followKeyword,
loadFollowList,
handleCancelPlan,
followFormKeyMap,
searchData,
activeStatus,
};
}

View File

@@ -2,12 +2,12 @@ export default {
'crmFormCreate.drawer.clue': 'Clue',
'crmFormCreate.drawer.customer': 'Customer',
'crmFormCreate.drawer.contact': 'Contact',
'crmFormCreate.drawer.recordCustomer': 'Follow-up record',
'crmFormCreate.drawer.planCustomer': 'Follow-up plan',
'crmFormCreate.drawer.record': 'Follow-up record',
'crmFormCreate.drawer.plan': 'Follow-up plan',
'crmFormCreate.drawer.recordBusiness': 'Follow-up record',
'crmFormCreate.drawer.planBusiness': 'Follow-up plan',
'crmFormCreate.drawer.recordClue': 'Follow-up record',
'crmFormCreate.drawer.planClue': 'Follow-up plan',
'crmFormCreate.drawer.business': 'Business',
'crmFormCreate.drawer.opportunity': 'Business',
'crmFormCreate.drawer.product': 'Product',
};

View File

@@ -2,12 +2,12 @@ export default {
'crmFormCreate.drawer.clue': '线索',
'crmFormCreate.drawer.customer': '客户',
'crmFormCreate.drawer.contact': '联系人',
'crmFormCreate.drawer.recordCustomer': '跟进记录',
'crmFormCreate.drawer.planCustomer': '跟进计划',
'crmFormCreate.drawer.record': '跟进记录',
'crmFormCreate.drawer.plan': '跟进计划',
'crmFormCreate.drawer.recordBusiness': '跟进记录',
'crmFormCreate.drawer.planBusiness': '跟进计划',
'crmFormCreate.drawer.recordClue': '跟进记录',
'crmFormCreate.drawer.planClue': '跟进计划',
'crmFormCreate.drawer.business': '商机',
'crmFormCreate.drawer.opportunity': '商机',
'crmFormCreate.drawer.product': '产品',
};

View File

@@ -29,12 +29,12 @@
}>();
const emit = defineEmits<{
(e: 'select', key: string): void;
(e: 'select', key: string, done?: () => void): void;
(e: 'cancel'): void;
}>();
function handleSelect(key: string) {
emit('select', key);
function handleSelect(key: string, done?: () => void) {
emit('select', key, done);
}
function handleMoreSelect(item: ActionsItem) {

View File

@@ -90,7 +90,7 @@
}>();
const emit = defineEmits<{
(e: 'buttonSelect', key: string): void;
(e: 'buttonSelect', key: string, done?: () => void): void;
}>();
const showDrawer = defineModel<boolean>('show', {
@@ -118,7 +118,7 @@
const realFormKey = ref<FormDesignKeyEnum>(props.formKey);
const realSourceId = ref<string | undefined>(''); // 编辑时传入
function handleButtonClick(key: string) {
function handleButtonClick(key: string, done?: () => void) {
switch (key) {
case 'addContract':
realFormKey.value = FormDesignKeyEnum.CONTACT;
@@ -152,7 +152,7 @@
default:
break;
}
emit('buttonSelect', key);
emit('buttonSelect', key, done);
}
watch(

View File

@@ -1,56 +1,69 @@
<template>
<div class="bg-[var(--text-n10)] p-[16px]">
<WorkflowStep v-model:status="currentStatus" :workflow-list="workflowList">
<template #action="{ currentStatusIndex }">
<n-button
v-if="props.showErrorBtn"
type="error"
ghost
class="n-btn-outline-error mr-[12px]"
@click="handleUpdateStatus(currentStatusIndex, true)"
>
{{ t('common.followFailed') }}
</n-button>
<n-button type="primary" :loading="updateStageLoading" @click="handleUpdateStatus(currentStatusIndex)">
{{ t('common.updateToCurrentProgress') }}
</n-button>
</template>
</WorkflowStep>
<n-spin :show="updateStageLoading">
<WorkflowStep v-model:status="currentStatus" :workflow-list="workflowList">
<template #action="{ currentStatusIndex }">
<n-button
v-if="props.showErrorBtn"
type="error"
ghost
class="n-btn-outline-error mr-[12px]"
@click="handleUpdateStatus(currentStatusIndex, true)"
>
{{ t('common.followFailed') }}
</n-button>
<n-button type="primary" @click="handleUpdateStatus(currentStatusIndex)">
{{ t('common.updateToCurrentProgress') }}
</n-button>
</template>
</WorkflowStep>
<CrmModal
v-model:show="updateStatusModal"
:title="t('common.complete')"
:ok-loading="loading"
size="small"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<n-form ref="formRef" :model="form" :rules="rules" label-placement="left" require-mark-placement="left">
<n-form-item
require-mark-placement="left"
label-placement="left"
path="stage"
:show-feedback="false"
:label="t('common.result')"
>
<n-radio-group v-model:value="form.stage" name="radiogroup">
<n-space>
<n-radio key="success" :value="StageResultEnum.SUCCESS">
{{ t('common.success') }}
</n-radio>
<n-radio key="fail" :value="StageResultEnum.FAIL">
{{ t('common.fail') }}
</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
</n-form>
</CrmModal>
<CrmModal
v-model:show="updateStatusModal"
:title="t('common.complete')"
:ok-loading="updateStageLoading"
size="small"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<n-form ref="formRef" :model="form" :rules="rules" label-placement="left" require-mark-placement="left">
<n-form-item
require-mark-placement="left"
label-placement="left"
path="stage"
:show-feedback="false"
:label="t('common.result')"
>
<n-radio-group v-model:value="form.stage" name="radiogroup">
<n-space>
<n-radio key="success" :value="StageResultEnum.SUCCESS">
{{ t('common.success') }}
</n-radio>
<n-radio key="fail" :value="StageResultEnum.FAIL">
{{ t('common.fail') }}
</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
</n-form>
</CrmModal>
</n-spin>
</div>
</template>
<script lang="ts" setup>
import { FormInst, FormRules, NButton, NForm, NFormItem, NRadio, NRadioGroup, NSpace, SelectOption } from 'naive-ui';
import {
FormInst,
FormRules,
NButton,
NForm,
NFormItem,
NRadio,
NRadioGroup,
NSpace,
NSpin,
SelectOption,
} from 'naive-ui';
import { StageResultEnum } from '@lib/shared/enums/opportunityEnum';
@@ -95,36 +108,27 @@
}
const formRef = ref<FormInst | null>(null);
async function executeWithLoading(cb: () => Promise<void>, loadingRef: Ref<boolean>) {
try {
loadingRef.value = true;
await cb();
emit('loadDetail');
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
loadingRef.value = false;
}
}
const updateStageLoading = ref(false);
async function handleSave(stage: string) {
try {
updateStageLoading.value = true;
if (props.updateApi) {
await props.updateApi({
id: props.sourceId,
stage,
});
emit('loadDetail');
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
updateStageLoading.value = false;
}
}
// 更新状态
const updateStageLoading = ref(false);
async function handleUpdateStatus(currentStatusIndex: number, isError = false) {
if (props.showConfirmStatus && currentStatusIndex === props.workflowList.length - 2) {
updateStatusModal.value = true;
@@ -132,15 +136,14 @@
}
const nextStage = isError ? StageResultEnum.FAIL : props.workflowList[currentStatusIndex + 1]?.value;
await executeWithLoading(() => handleSave(nextStage as string), updateStageLoading);
await handleSave(nextStage as string);
}
// 确认更新
const loading = ref(false);
async function handleConfirm() {
formRef.value?.validate(async (errors) => {
if (!errors) {
await executeWithLoading(() => handleSave(form.value.stage), loading);
await handleSave(form.value.stage);
handleCancel();
}
});

View File

@@ -4,10 +4,10 @@
<slot :name="item.slotName" :item="item" :index="index">
<template v-if="item.popConfirmProps">
<CrmPopConfirm
:show="item.popShow as boolean"
v-model:show="popShow"
placement="bottom-end"
v-bind="item.popConfirmProps"
@confirm="emit('select', `pop-${item.key}` as string)"
@confirm="emit('select', `pop-${item.key}` as string, cancel)"
@cancel="emit('cancel')"
>
<n-button
@@ -15,7 +15,7 @@
v-bind="item"
type="primary"
:class="item.text === false ? '' : '!p-0'"
@click="() => (item.popShow = true)"
@click="() => (popShow = true)"
>
{{ item.label }}
</n-button>
@@ -52,10 +52,16 @@
notShowDivider?: boolean; // 不显示分割线
}>();
const popShow = ref(false);
const emit = defineEmits<{
(e: 'select', key: string): void;
(e: 'select', key: string, done?: () => void): void;
(e: 'cancel'): void;
}>();
function cancel() {
popShow.value = false;
}
</script>
<style scoped lang="less">

View File

@@ -29,18 +29,12 @@
<template #right>
<FollowDetail
v-if="['followRecord', 'followPlan'].includes(activeTab)"
v-model:data="followList"
v-model:active-status="activeStatus"
class="mt-[16px]"
:loading="followLoading"
:show-title="activeTab === 'followRecord'"
:type="activeTab"
wrapper-class="h-[calc(100vh-258px)]"
virtual-scroll-height="calc(100vh - 290px)"
@reach-bottom="handleReachBottom"
@search="() => loadFollowList()"
@cancel-plan="handleCancelPlan"
@handle-edit="handleEditFollow"
:active-type="(activeTab as 'followRecord'| 'followPlan')"
wrapper-class="h-[calc(100vh-290px)]"
virtual-scroll-height="calc(100vh - 322px)"
:follow-api-key="FormDesignKeyEnum.CLUE"
:source-id="sourceId"
/>
<!-- TODO getUserList接口换了 -->

View File

@@ -20,17 +20,12 @@
<template #right>
<FollowDetail
v-if="['followRecord', 'followPlan'].includes(activeTab)"
v-model:data="followList"
v-model:active-status="activeStatus"
class="mt-[16px]"
:loading="followLoading"
:show-title="activeTab === 'followRecord'"
:type="activeTab"
:active-type="(activeTab as 'followRecord'| 'followPlan')"
wrapper-class="h-[calc(100vh-162px)]"
virtual-scroll-height="calc(100vh - 258px)"
@reach-bottom="handleReachBottom"
@search="() => loadFollowList()"
@handle-edit="handleEditFollow"
:follow-api-key="FormDesignKeyEnum.CLUE"
:source-id="sourceId"
/>
<!-- TODO getUserList接口换了 -->

View File

@@ -28,6 +28,15 @@
<div class="mt-[16px]">
<div v-if="activeTab === 'overview'" class="mt-[16px] h-[100px] bg-[var(--text-n10)]"></div>
<ContactTable v-else-if="activeTab === 'contact'" class="h-[calc(100vh-161px)]" is-overview />
<FollowDetail
v-else-if="['followRecord', 'followPlan'].includes(activeTab)"
class="mt-[16px]"
:active-type="(activeTab as 'followRecord'| 'followPlan')"
wrapper-class="h-[calc(100vh-162px)]"
virtual-scroll-height="calc(100vh - 194px)"
:follow-api-key="FormDesignKeyEnum.CUSTOMER"
:source-id="props.sourceId"
/>
<HeaderTable
v-else-if="activeTab === 'headRecord'"
class="h-[calc(100vh-161px)]"
@@ -48,6 +57,7 @@
import { ModuleConfigEnum } from '@lib/shared/enums/moduleEnum';
import type { ActionsItem } from '@/components/pure/crm-more-action/type';
import FollowDetail from '@/components/business/crm-follow-detail/index.vue';
import ContactTable from '@/components/business/crm-form-create-table/contactTable.vue';
import HeaderTable from '@/components/business/crm-form-create-table/headerTable.vue';
import CrmFormDescription from '@/components/business/crm-form-description/index.vue';

View File

@@ -21,39 +21,28 @@
class="mb-[16px]"
:workflow-list="workflowList"
:source-id="sourceId"
:save-api="updateOptStage"
:update-api="updateOptStage"
@load-detail="loadStageDetail"
/>
</template>
<template #right>
<FollowDetail
v-if="['followRecord', 'followPlan'].includes(activeTab)"
v-model:data="followList"
v-model:active-status="activeStatus"
class="mt-[16px]"
:loading="followLoading"
:show-title="activeTab === 'followRecord'"
:type="activeTab"
wrapper-class="h-[calc(100vh-162px)]"
virtual-scroll-height="calc(100vh - 194px)"
@reach-bottom="handleReachBottom"
@search="() => loadFollowList()"
@cancel-plan="handleCancelPlan"
@handle-edit="handleEditFollow"
:active-type="(activeTab as 'followRecord'| 'followPlan')"
wrapper-class="h-[calc(100vh-290px)]"
virtual-scroll-height="calc(100vh - 322px)"
:follow-api-key="FormDesignKeyEnum.BUSINESS"
:source-id="sourceId"
/>
<HeaderTable
v-if="activeTab === 'headRecord'"
class="mt-[16px] h-[calc(100vh-161px)]"
class="mt-[16px] h-[calc(100vh-290px)]"
:source-id="sourceId"
:load-list-api="getUserList"
/>
<CrmFormCreateDrawer
v-model:visible="formDrawerVisible"
:form-key="realFormKey"
:source-id="realFollowSourceId"
/>
</template>
<template #transferPopContent>
@@ -65,15 +54,13 @@
<script setup lang="ts">
import { SelectOption, useMessage } from 'naive-ui';
import { CustomerFollowPlanStatusEnum } from '@lib/shared/enums/customerEnum';
import { FormDesignKeyEnum } from '@lib/shared/enums/formDesignEnum';
import { OpportunityStatusEnum, StageResultEnum } from '@lib/shared/enums/opportunityEnum';
import type { FollowDetailItem, TransferParams } from '@lib/shared/models/customer';
import type { TransferParams } from '@lib/shared/models/customer';
import type { OpportunityItem } from '@lib/shared/models/opportunity';
import type { ActionsItem } from '@/components/pure/crm-more-action/type';
import FollowDetail from '@/components/business/crm-follow-detail/index.vue';
import CrmFormCreateDrawer from '@/components/business/crm-form-create-drawer/index.vue';
import HeaderTable from '@/components/business/crm-form-create-table/headerTable.vue';
import CrmFormDescription from '@/components/business/crm-form-description/index.vue';
import CrmOverviewDrawer from '@/components/business/crm-overview-drawer/index.vue';
@@ -81,15 +68,7 @@
import TransferForm from '@/components/business/crm-transfer-modal/transferForm.vue';
import CrmWorkflowCard from '@/components/business/crm-workflow-card/index.vue';
import {
cancelOptFollowPlan,
deleteOpt,
getOptFollowPlanList,
getOptFollowRecordList,
getOptStageDetail,
transferOpt,
updateOptStage,
} from '@/api/modules/opportunity';
import { deleteOpt, getOptStageDetail, transferOpt, updateOptStage } from '@/api/modules/opportunity';
import { getUserList } from '@/api/modules/system/org';
import { defaultTransferForm } from '@/config/opportunity';
import { useI18n } from '@/hooks/useI18n';
@@ -221,81 +200,9 @@
return [...props.baseSteps, endStage.value];
});
const pageNation = ref({
total: 0,
pageSize: 10,
current: 1,
});
const followList = ref<FollowDetailItem[]>([]);
const activeStatus = ref(CustomerFollowPlanStatusEnum.ALL);
const followLoading = ref<boolean>(false);
async function loadFollowList() {
try {
followLoading.value = true;
const params = {
sourceId: sourceId.value,
current: pageNation.value.current || 1,
pageSize: pageNation.value.pageSize,
};
let res;
if (activeTab.value === 'followPlan') {
res = await getOptFollowPlanList({
...params,
status: activeStatus.value,
});
} else {
res = await getOptFollowRecordList(params);
}
followList.value = res.list || [];
pageNation.value.total = res.total;
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
followLoading.value = false;
}
}
function handleReachBottom() {
pageNation.value.current += 1;
if (pageNation.value.current > Math.ceil(pageNation.value.total / pageNation.value.pageSize)) {
return;
}
loadFollowList();
}
const formDrawerVisible = ref(false);
const realFormKey = ref<FormDesignKeyEnum>(FormDesignKeyEnum.FOLLOW_RECORD_BUSINESS);
// 取消计划
async function handleCancelPlan(item: FollowDetailItem) {
try {
await cancelOptFollowPlan(item.id);
Message.success(t('common.cancelSuccess'));
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
}
// 编辑跟进内容
const realFollowSourceId = ref<string | undefined>('');
function handleEditFollow(item: FollowDetailItem) {
realFormKey.value =
activeTab.value === 'followRecord'
? FormDesignKeyEnum.FOLLOW_RECORD_BUSINESS
: FormDesignKeyEnum.FOLLOW_PLAN_BUSINESS;
realFollowSourceId.value = item.id;
formDrawerVisible.value = true;
}
// 转移
const transferFormRef = ref<InstanceType<typeof TransferForm>>();
function handleTransfer() {
function handleTransfer(done?: () => void) {
transferFormRef.value?.formRef?.validate(async (error) => {
if (!error) {
try {
@@ -307,6 +214,7 @@
Message.success(t('common.transferSuccess'));
transferForm.value = { ...defaultTransferForm };
showOptOverviewDrawer.value = false;
done?.();
emit('refresh');
} catch (e) {
// eslint-disable-next-line no-console
@@ -349,10 +257,10 @@
}
}
function handleSelect(key: string) {
function handleSelect(key: string, done?: () => void) {
switch (key) {
case 'pop-transfer':
handleTransfer();
handleTransfer(done);
break;
case 'delete':
handleDelete();
@@ -362,20 +270,10 @@
}
}
watch(
() => activeTab.value,
(val) => {
if (['followPlan', 'followRecord'].includes(val)) {
loadFollowList();
}
}
);
watch(
() => showOptOverviewDrawer.value,
(val) => {
if (val) {
loadFollowList();
loadStageDetail();
}
}

View File

@@ -1,6 +1,6 @@
<template>
<CrmCard no-content-padding hide-footer auto-height class="mb-[16px]">
<CrmTab v-model:active-tab="activeTab" no-content :tab-list="tabList" type="line" />
<CrmTab v-model:active-tab="activeTab" no-content :tab-list="tabList" type="line" @change="() => searchData()" />
</CrmCard>
<CrmCard hide-footer>
<CrmTable
@@ -72,7 +72,7 @@
import { DataTableRowKey, NButton, SelectOption, TabPaneProps, useMessage } from 'naive-ui';
import { FieldTypeEnum, FormDesignKeyEnum } from '@lib/shared/enums/formDesignEnum';
import { OpportunityStatusEnum, StageResultEnum } from '@lib/shared/enums/opportunityEnum';
import { OpportunitySearchTypeEnum, OpportunityStatusEnum, StageResultEnum } from '@lib/shared/enums/opportunityEnum';
import type { TransferParams } from '@lib/shared/models/customer/index';
import type { OpportunityItem } from '@lib/shared/models/opportunity';
@@ -123,25 +123,25 @@
};
const tableRefreshId = ref(0);
const activeTab = ref('all');
const activeTab = ref(OpportunitySearchTypeEnum.ALL);
const tabList = computed<TabPaneProps[]>(() => {
// TODO 根据不同的用户展示tab
return [
{
name: 'all',
name: OpportunitySearchTypeEnum.ALL,
tab: t('opportunity.allOpportunities'),
},
{
name: 'my',
name: OpportunitySearchTypeEnum.SELF,
tab: t('opportunity.myOpportunities'),
},
{
name: 'department',
name: OpportunitySearchTypeEnum.DEPARTMENT,
tab: t('opportunity.departmentOpportunities'),
},
{
name: 'converted',
name: OpportunitySearchTypeEnum.DEAL,
tab: t('opportunity.convertedOpportunities'),
},
];
@@ -192,7 +192,7 @@
}
}
const showOverviewDrawer = ref<boolean>(true);
const showOverviewDrawer = ref<boolean>(false);
const activeSourceId = ref('');
const activeOpportunity = ref<OpportunityItem>();
const formCreateDrawerVisible = ref(false);
@@ -250,7 +250,7 @@
const transferLoading = ref(false);
// 转移
function handleTransfer(row: OpportunityItem) {
function handleTransfer(row: OpportunityItem, done?: () => void) {
transferFormRef.value?.formRef?.validate(async (error) => {
if (!error) {
try {
@@ -262,6 +262,7 @@
Message.success(t('common.transferSuccess'));
transferForm.value = { ...defaultTransferForm };
tableRefreshId.value += 1;
done?.();
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
@@ -272,7 +273,7 @@
});
}
function handleActionSelect(row: OpportunityItem, actionKey: string) {
function handleActionSelect(row: OpportunityItem, actionKey: string, done?: () => void) {
switch (actionKey) {
case 'edit':
handleEdit(row.id);
@@ -281,7 +282,7 @@
handleFollowUp(row.id);
break;
case 'pop-transfer':
handleTransfer(row);
handleTransfer(row, done);
break;
case 'delete':
handleDelete(row);
@@ -331,7 +332,7 @@
CrmOperationButton,
{
groupList: operationGroupList.value,
onSelect: (key: string) => handleActionSelect(row, key),
onSelect: (key: string, done?: () => void) => handleActionSelect(row, key, done),
onCancel: () => {
transferForm.value = { ...defaultTransferForm };
},
@@ -364,16 +365,14 @@
},
customerName: (row: OpportunityItem) => {
return h(
NButton,
CrmTableButton,
{
text: true,
type: 'primary',
onClick: () => {
activeSourceId.value = row.customerId;
realFormKey.value = FormDesignKeyEnum.CUSTOMER;
},
},
{ default: () => row.customerName }
{ default: () => row.customerName, trigger: () => row.customerName }
);
},
},
@@ -460,6 +459,7 @@
function searchData() {
setLoadListParams({
keyword: keyword.value,
searchType: activeTab.value,
});
loadList();
}

View File

@@ -208,4 +208,4 @@
});
</script>
<style lang="scss" scoped></style>
<style lang="less" scoped></style>