Files
DJKB/my-vue-app/src/views/person/sale.vue
lbw_9527443 c10b514779 feat(销售时间轴): 添加子时间轴阶段选择功能
实现子时间轴各阶段的点击选择功能,将筛选后的客户数据转换为统一格式并传递给父组件
2025-08-30 17:19:53 +08:00

1799 lines
50 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="sales-dashboard">
<!-- 页面加载状态 -->
<Loading :visible="isPageLoading" text="正在加载数据..." />
<!-- 顶部导航栏 -->
<!-- 销售时间线区域 -->
<section class="timeline-section">
<div class="section-header">
<!-- 动态顶栏根据是否有路由参数显示不同内容 -->
<!-- 路由跳转时的顶栏面包屑 + 姓名 -->
<div v-if="isRouteNavigation" class="route-header" style="display: flex; justify-content: space-between; align-items: center;">
<div class="breadcrumb" style="display: flex; flex-direction: column;">
<span class="breadcrumb-item" @click="goBack">团队管理 >{{ routeUserName }}</span>
<span class="breadcrumb-item current"> 数据驾驶舱</span>
</div>
<div class="user-name">
{{ routeUserName }}
</div>
</div>
<!-- 自己登录时的顶栏原有样式 -->
<template v-else>
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<h1 class="app-title">销售驾驶舱</h1>
<div
class="quick-stats"
style="display: flex; align-items: center; gap: 30px"
>
</div>
<UserDropdown />
</div>
</template>
</div>
<div class="section-content">
<!-- 销售时间线加载状态 -->
<div v-if="isTimelineLoading" class="section-loading">
<div class="loading-spinner"></div>
<div class="loading-text">正在加载销售时间线...</div>
</div>
<!-- 销售时间线内容 -->
<SalesTimelineWithTaskList
v-else
:data="timelineData"
@stage-select="handleStageSelect"
@sub-stage-select="handleSubStageSelect"
:selected-stage="selectedStage"
:contacts="filteredContacts"
:selected-contact-id="selectedContactId"
:course-customers="courseCustomers"
:kpi-data="kpiData"
:customers-list="customersList"
:customers-count="customersCount"
:pay-money-customers-list="payMoneyCustomersList"
:pay-money-customers-count="payMoneyCustomersCount"
@select-contact="selectContact" />
</div>
</section>
<!-- 原始数据卡片区域 -->
<section class="raw-data-section">
<div class="section-header">
<h2>原始数据</h2>
<p class="section-subtitle">客户互动的原始记录和数据</p>
</div>
<div class="section-content">
<RawDataCards
:selected-contact="selectedContact"
:form-info="formInfo"
:chat-info="chatRecords"
:call-info="callRecords"
@view-form-data="handleViewFormData"
@view-chat-data="handleViewChatData"
@view-call-data="handleViewCallData"
@analyze-sop="handleAnalyzeSop" />
</div>
</section>
<!-- 主要内容区域 -->
<div class="main-layout">
<!-- 主要工作区域 -->
<main class="main-content">
<!-- 客户详情区域 -->
<section class="detail-section">
<div class="section-header">
<h2>客户详情</h2>
</div>
<div class="section-content">
<CustomerDetail
ref="customerDetailRef"
:selected-contact="selectedContact"
:form-info="formInfo"
:chat-records="chatRecords"
:call-records="callRecords" />
</div>
</section>
</main>
</div>
<section class="analytics-section-full" style="width: 100%;">
<div class="section-content">
<!-- 数据分析区域加载状态 -->
<div v-if="isKpiLoading || isStatisticsLoading || isUrgentProblemLoading" class="section-loading">
<div class="loading-spinner"></div>
<div class="loading-text">正在加载数据分析...</div>
</div>
<!-- 数据分析内容 -->
<PersonalDashboard
v-else
:kpi-data="kpiData"
:funnel-data="funnelData"
:contact-time-data="goldContactTime"
:statistics-data="statisticsData"
:urgent-problem-data="urgentProblemData"
/>
</div>
</section>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from "vue";
import { useRouter } from "vue-router";
import { useUserStore } from "@/stores/user";
import CustomerDetail from "./components/CustomerDetail.vue";
import PersonalDashboard from "./components/PersonalDashboard.vue";
import SalesTimelineWithTaskList from "./components/SalesTimelineWithTaskList.vue";
import RawDataCards from "./components/RawDataCards.vue";
import UserDropdown from "@/components/UserDropdown.vue";
import Loading from "@/components/Loading.vue";
import {getCustomerAttendance,getTodayCall,getProblemDistribution,getTableFillingRate,getAverageResponseTime,
getWeeklyActiveCommunicationRate,getTimeoutResponseRate,getCustomerCallInfo,getCustomerChatInfo,getCustomerFormInfo,
getConversionRateAndAllocatedData,getCustomerAttendanceAfterClass4,getPayMoneyCustomers,getSalesFunnel,getGoldContactTime,getAvgCallTime} from "@/api/api.js"
// 缓存系统
const cache = new Map();
const CACHE_DURATION = 30 * 60 * 1000; // 30分钟缓存时长
// 生成缓存键
const getCacheKey = (apiName, params = {}) => {
const sortedParams = Object.keys(params)
.sort()
.reduce((result, key) => {
result[key] = params[key];
return result;
}, {});
return `${apiName}_${JSON.stringify(sortedParams)}`;
};
// 检查缓存是否有效
const isValidCache = (cacheData) => {
return cacheData && (Date.now() - cacheData.timestamp) < CACHE_DURATION;
};
// 设置缓存
const setCache = (key, data) => {
cache.set(key, {
data,
timestamp: Date.now()
});
};
// 获取缓存
const getCache = (key) => {
const cacheData = cache.get(key);
if (isValidCache(cacheData)) {
return cacheData.data;
}
// 如果缓存过期,删除它
if (cacheData) {
cache.delete(key);
}
return null;
};
// 缓存包装器函数
const withCache = async (apiName, apiFunction, params = {}) => {
const cacheKey = getCacheKey(apiName, params);
const cachedData = getCache(cacheKey);
if (cachedData) {
console.log(`[缓存命中] ${apiName}:`, cachedData);
return cachedData;
}
console.log(`[API调用] ${apiName}`);
const result = await apiFunction(params);
setCache(cacheKey, result);
return result;
};
// 路由实例
const router = useRouter();
// 用户store
const userStore = useUserStore();
// 获取通用请求参数的函数
const getRequestParams = () => {
const params = {}
// 只从路由参数获取
const routeUserLevel = router.currentRoute.value.query.user_level || router.currentRoute.value.params.user_level
const routeUserName = router.currentRoute.value.query.user_name || router.currentRoute.value.params.user_name
// 如果路由有参数,使用路由参数
if (routeUserLevel) {
params.user_level = routeUserLevel.toString()
}
if (routeUserName) {
params.user_name = routeUserName
}
return params
}
// 判断是否为路由导航(有路由参数)
const isRouteNavigation = computed(() => {
const routeUserName = router.currentRoute.value.query.user_name || router.currentRoute.value.params.user_name
return !!routeUserName
})
// 获取路由传递的用户名
const routeUserName = computed(() => {
return router.currentRoute.value.query.user_name || router.currentRoute.value.params.user_name || ''
})
// 返回上一页
const goBack = () => {
router.go(-1)
}
// STATE
const selectedContactId = ref(null);
const contextPanelRef = ref(null);
const customerDetailRef = ref(null);
const selectedStage = ref('全部'); // 选中的销售阶段
const isPageLoading = ref(true); // 页面整体加载状态
const isKpiLoading = ref(false); // KPI数据加载状态
const isStatisticsLoading = ref(false); // 统计数据加载状态
const isUrgentProblemLoading = ref(false); // 紧急问题数据加载状态
const isTimelineLoading = ref(false); // 时间线数据加载状态
// KPI数据
const kpiDataState = reactive({
totalCalls: 85,
successRate: 28,
avgDuration: 125,
conversionRate: 12,
assignedData: 256,
wechatAddRate: 68
});
// 统计指标数据
const statisticsData = reactive({
customerCommunicationRate: 0,
averageResponseTime: 0,
timeoutResponseRate: 0,
severeTimeoutRate: 0,
formCompletionRate: 0
});
// 客户迫切解决的问题数据
const urgentProblemData = ref([]);
// 时间线数据
const timelineData = ref({});
// 客户列表数据
const customersList = ref([]);
// 客户总数
const customersCount = ref(0);
// 课程客户数据课1-4
const courseCustomers = ref({});
// 成交阶段客户数据
const payMoneyCustomersList = ref([]);
const payMoneyCustomersCount = ref(0);
// 表单信息
const formInfo = ref({});
// 通话记录
const callRecords = ref([]);
// 聊天记录
const chatRecords = ref([]);
// MOCK DATA (Should ideally come from a store or API)
const MOCK_DATA = reactive({
contacts: [
// 今日必须联系 (high priority) - 12个任务
{
id: 1,
name: "王女士",
time: "今日 10:00",
}
],
performance: {
calls: { label: "呼叫量", value: 85, target: 100 },
talkTime: { label: "通话时长(分)", value: 125, target: 180 },
revenue: { label: "签单额(元)", value: 8500, target: 15000 },
},
personalKPIs: {
winRate: { label: "赢率", value: 28, unit: "%" },
avgDealSize: { label: "平均客单价", value: 12500, unit: "元" },
salesCycle: { label: "销售周期", value: 45, unit: "天" },
},
personalFunnel: [200, 150, 90, 40, 12],
commission: { confirmed: 4250, estimated: 7800, nextDealValue: 350 },
contactTimeAnalysis: {
labels: ["9-10点", "10-11点", "11-12点", "14-15点", "15-16点", "16-17点"],
data: [65, 85, 80, 92, 75, 60],
description: "基于历史通话数据统计的客户接听成功率",
},
});
// 核心Kpi
async function getCoreKpi() {
isKpiLoading.value = true
try {
const params = getRequestParams()
const hasParams = params.user_name
// 今日通话数据
const res = await withCache('getTodayCall', () => getTodayCall(hasParams ? params : undefined), hasParams ? params : {})
if (res.code === 200) {
kpiDataState.totalCalls = res.data.today_call
}
// 转化率、分配数据量、加微率
const conversionRes = await withCache('getConversionRateAndAllocatedData', () => getConversionRateAndAllocatedData(hasParams ? params : undefined), hasParams ? params : {})
if (conversionRes.code === 200) {
kpiDataState.conversionRate = conversionRes.data.conversion_rate || 0
kpiDataState.assignedData = conversionRes.data.all_count || 0
kpiDataState.wechatAddRate = conversionRes.data.plus_v_conversion_rate || 0
}
// 平均通话时长
const avgCallTimeRes = await withCache('getAvgCallTime', () => getAvgCallTime(hasParams ? params : undefined), hasParams ? params : {})
if (avgCallTimeRes.code === 200) {
kpiDataState.avgDuration = avgCallTimeRes.data.call_time || 0
}
} catch (error) {
console.error('获取核心KPI数据失败:', error)
} finally {
isKpiLoading.value = false
}
}
// 获取统计数据
async function getStatisticsData() {
isStatisticsLoading.value = true
try {
const params = getRequestParams()
const hasParams = params.user_name
// 获取表单填写率
const fillingRateRes = await withCache('getTableFillingRate', () => getTableFillingRate(hasParams ? params : undefined), hasParams ? params : {})
if (fillingRateRes.code === 200) {
statisticsData.formCompletionRate = fillingRateRes.data.filling_rate
}
// 获取平均响应时间
const avgResponseRes = await withCache('getAverageResponseTime', () => getAverageResponseTime(hasParams ? params : undefined), hasParams ? params : {})
if (avgResponseRes.code === 200) {
statisticsData.averageResponseTime = avgResponseRes.data.average_minutes
}
// 获取客户沟通率
const communicationRes = await withCache('getWeeklyActiveCommunicationRate', () => getWeeklyActiveCommunicationRate(hasParams ? params : undefined), hasParams ? params : {})
if (communicationRes.code === 200) {
statisticsData.customerCommunicationRate = communicationRes.data.communication_rate
}
// 获取超时响应率
const timeoutRes = await withCache('getTimeoutResponseRate', () => getTimeoutResponseRate(hasParams ? params : undefined), hasParams ? params : {})
if (timeoutRes.code === 200) {
statisticsData.timeoutResponseRate = timeoutRes.data.overtime_rate_600
statisticsData.severeTimeoutRate = timeoutRes.data.overtime_rate_800
}
} catch (error) {
console.error('获取统计数据失败:', error)
} finally {
isStatisticsLoading.value = false
}
}
// 客户迫切解决的问题
async function getUrgentProblem() {
isUrgentProblemLoading.value = true
try {
const params = getRequestParams()
const hasParams = params.user_name
const res = await withCache('getProblemDistribution', () => getProblemDistribution(hasParams ? params : undefined), hasParams ? params : {})
if(res.code === 200) {
// 将API返回的对象格式转换为数组格式
const problemDistributionCount = res.data.problem_distribution_count
urgentProblemData.value = Object.entries(problemDistributionCount).map(([name, value]) => ({ name, value }))
}
} catch (error) {
console.error('获取紧急问题数据失败:', error)
} finally {
isUrgentProblemLoading.value = false
}
}
// 时间线
async function getTimeline() {
isTimelineLoading.value = true
try {
const params = getRequestParams()
const hasParams = params.user_name
// 前6个阶段
const res = await withCache('getCustomerAttendance', () => getCustomerAttendance(hasParams ? params : undefined), hasParams ? params : {})
if(res.code === 200) {
// 处理时间线数据
if (res.data.timeline) {
timelineData.value = Object.entries(res.data.timeline).map(([name, count]) => ({
name: name,
value: parseInt(count) || 0
}))
}
// 处理客户列表数据
if (res.data.all_customers_list) {
customersList.value = res.data.all_customers_list
}
// 处理客户总数
if (res.data.all_customers_count) {
customersCount.value = res.data.all_customers_count
}
}
// 后4个阶段
const classRes = await withCache('getCustomerAttendanceAfterClass4', () => getCustomerAttendanceAfterClass4(hasParams ? params : undefined), hasParams ? params : {})
if(classRes.code === 200) {
// 处理课1-4阶段的客户数据
if (classRes.data.class_customers_list) {
console.log(8888999,courseCustomers.value)
// 存储课1-4阶段的原始数据根据pay_status设置正确的type
courseCustomers.value['课1-4'] = classRes.data.class_customers_list.map(customer => {
let customerType = ''; // 默认类型
// 根据pay_status设置具体的type
if (customer.pay_status === '点击未支付') {
customerType = '点击未支付';
} else if (customer.pay_status === '付定金') {
customerType = '付定金';
} else if (customer.pay_status === '定金转化' || customer.pay_status === '已转化') {
customerType = '定金转化';
}
return {
id: customer.customer_name,
name: customer.customer_name,
phone: customer.phone,
profession: customer.customer_occupation,
education: customer.customer_child_education,
avatar: customer.weChat_avatar || '/default-avatar.svg',
time: customer.messages_last_time,
type: customerType, // 根据pay_status设置的type
health: 75, // 默认健康度
customer_name: customer.customer_name,
customer_occupation: customer.customer_occupation,
customer_child_education: customer.customer_child_education,
scrm_user_main_code: customer.scrm_user_main_code,
weChat_avatar: customer.weChat_avatar,
class_situation: customer.class_situation,
class_num: Object.keys(customer.class_situation || {}), // 添加class_num字段
pay_status: customer.pay_status,
records: []
};
})
// 根据pay_status筛选后三个阶段的客户
const allClassCustomers = classRes.data.class_customers_list
// 点击未支付阶段 - 可以根据具体业务逻辑调整筛选条件
const unpaidCustomers = allClassCustomers.filter(customer =>
customer.pay_status === '未支付' || customer.pay_status === '点击未支付'
)
// 付定金阶段
const depositCustomers = allClassCustomers.filter(customer =>
customer.pay_status === '付定金'
)
// 定价转化阶段 - 可以根据具体业务逻辑调整筛选条件
const conversionCustomers = allClassCustomers.filter(customer =>
customer.pay_status === '定金转化' || customer.pay_status === '已转化'
)
// 将筛选后的客户添加到对应的customersList中
const formatCustomerForList = (customer, type) => ({
customer_name: customer.customer_name,
phone: customer.phone,
customer_occupation: customer.customer_occupation,
customer_child_education: customer.customer_child_education,
latest_message_time: customer.messages_last_time,
customer_avatar_url: customer.weChat_avatar,
type: type,
class_num: Object.keys(customer.class_situation || {}),
class_situation: customer.class_situation,
scrm_user_main_code: customer.scrm_user_main_code,
weChat_avatar: customer.weChat_avatar,
pay_status: customer.pay_status
})
// 后三个阶段的客户数据已存储在courseCustomers['课1-4']中不需要合并到customersList
}
}
// 成交阶段
const payRes = await withCache('getPayMoneyCustomers', () => getPayMoneyCustomers(hasParams ? params : undefined), hasParams ? params : {})
if(payRes.code === 200) {
// 处理成交阶段的客户数据
if (payRes.data.pay_money_customers_list) {
payMoneyCustomersList.value = payRes.data.pay_money_customers_list
}
// 处理成交阶段客户总数
if (payRes.data.pay_money_customers_count) {
payMoneyCustomersCount.value = payRes.data.pay_money_customers_count
}
// 成交阶段客户数据已存储在payMoneyCustomersList中不需要合并到customersList
}
} catch (error) {
console.error('获取时间线数据失败:', error)
} finally {
isTimelineLoading.value = false
}
}
// 获取客户表单
async function getCustomerForm() {
if (!selectedContact.value || !selectedContact.value.name) {
console.warn('无法获取客户表单:客户信息不完整');
return;
}
const routeParams = getRequestParams()
const params = {
user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name,
}
try {
const res = await withCache('getCustomerFormInfo', () => getCustomerFormInfo(params), params)
if(res.code === 200) {
formInfo.value = res.data
}
} catch (error) {
// 静默处理错误
}
}
// 聊天记录
async function getCustomerChat() {
if (!selectedContact.value || !selectedContact.value.name) {
console.warn('无法获取客户聊天记录:客户信息不完整');
return;
}
const routeParams = getRequestParams()
const params = {
user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name,
}
try {
const res = await withCache('getCustomerChatInfo', () => getCustomerChatInfo(params), params)
if(res.code === 200) {
chatRecords.value = res.data
console.log('聊天数据获取成功:', res.data)
console.log('chatRecords.value:', chatRecords.value)
} else {
console.log('聊天数据获取失败:', res)
}
} catch (error) {
// 静默处理错误
}
}
// 通话记录
async function getCustomerCall() {
if (!selectedContact.value || !selectedContact.value.name) {
console.warn('无法获取客户通话记录:客户信息不完整');
return;
}
const routeParams = getRequestParams()
const params = {
user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name,
}
try {
const res = await withCache('getCustomerCallInfo', () => getCustomerCallInfo(params), params)
if(res.code === 200) {
callRecords.value = res.data
console.log('Call Records Data from API:', res.data)
console.log('callRecords.value after assignment:', callRecords.value)
/**
* "data": {
"user_name": "常琳",
"customer_name": "191桐桐爸爸高一男",
"record_file_addr_list": [
"http://192.168.3.112:5000/api/record/download/杨振彦-20分钟通话-25-08-19_07-23-37-744009-835.mp3"
]
}
*/
}
} catch (error) {
// 静默处理错误
}
}
// 为组件准备数据
const kpiData = computed(() => kpiDataState);
// COMPUTED PROPERTIES
const selectedContact = computed(() => {
// 优先从API数据中查找
if (formattedCustomersList.value.length > 0) {
return formattedCustomersList.value.find((c) => c.id === selectedContactId.value) || null;
}
// 否则从MOCK数据中查找
return MOCK_DATA.contacts.find((c) => c.id === selectedContactId.value) || null;
});
const funnelData = computed(() => {
if (!SalesFunnel.value || !SalesFunnel.value.sale_funnel) {
return {
labels: ["线索总数", "有效沟通", "到课数据", "预付定金", "成功签单"],
data: [0, 0, 0, 0, 0]
};
}
const funnel = SalesFunnel.value.sale_funnel;
return {
labels: ["线索总数", "有效沟通", "到课数据", "预付定金", "成功签单"],
data: [
funnel.线索总数 || 0,
funnel.有效沟通 || 0,
funnel.到课数据 || 0,
funnel.预付定金 || 0,
funnel.成功签单 || 0
]
};
});
const contactTimeData = computed(() => ({
labels: MOCK_DATA.contactTimeAnalysis.labels,
data: MOCK_DATA.contactTimeAnalysis.data,
}));
// 格式化客户列表数据
const formattedCustomersList = computed(() => {
if (!customersList.value || customersList.value.length === 0) {
return [];
}
return customersList.value.map(customer => ({
id: customer.customer_name, // 使用客户姓名作为唯一标识
name: customer.customer_name,
phone: customer.phone,
profession: customer.customer_occupation,
education: customer.customer_child_education,
lastMessageTime: customer.latest_message_time,
avatarUrl: customer.customer_avatar_url,
type: customer.type,
class_num: customer.class_num, // 确保字段名一致
// 添加一些默认值以兼容现有组件
salesStage: customer.type || '待联系',
priority: customer.type === '待联系' ? 'high' : 'normal',
time: customer.latest_message_time || '未知'
}));
});
// 根据选中阶段筛选联系人
const filteredContacts = computed(() => {
// 优先使用当前筛选的客户数据(来自阶段选择)
if (currentFilteredCustomers.value.length > 0) {
return currentFilteredCustomers.value;
}
// 对于课1-4阶段如果没有currentFilteredCustomers数据返回空数组
// 避免使用formattedCustomersList中type可能为undefined的数据
if (selectedStage.value === '课1-4') {
return [];
}
// 如果有API数据使用API数据
if (formattedCustomersList.value.length > 0) {
if (selectedStage.value === 'all' || selectedStage.value === '全部') {
return formattedCustomersList.value;
}
return formattedCustomersList.value.filter(contact => contact.salesStage === selectedStage.value);
}
// 否则使用MOCK数据
if (selectedStage.value === 'all' || selectedStage.value === '全部') {
return MOCK_DATA.contacts.filter(contact => contact.salesStage === '全部');
}
return MOCK_DATA.contacts.filter(contact => contact.salesStage === selectedStage.value);
});
// METHODS
const selectContact = (id) => {
selectedContactId.value = id;
// 当选中客户后,获取客户表单数据
nextTick(async () => {
if (selectedContact.value && selectedContact.value.name) {
await getStatisticsData()
await getCustomerForm();
await getCustomerChat();
await getCustomerCall();
}
contextPanelRef.value?.scrollIntoView({
behavior: "smooth",
block: "center",
});
});
};
// 存储当前筛选的客户数据
const currentFilteredCustomers = ref([]);
// 处理时间线阶段选择
const handleStageSelect = (stage, extraData = null) => {
selectedStage.value = stage;
// 如果传递了筛选后的客户数据,使用这些数据
if (extraData && extraData.filteredCustomers) {
// 将筛选后的客户数据转换为contacts格式
const filteredContacts = extraData.filteredCustomers.map(customer => ({
id: customer.customer_name || customer.id,
name: customer.customer_name || customer.name,
phone: customer.phone,
profession: customer.customer_occupation || customer.profession,
education: customer.customer_child_education || customer.education,
lastMessageTime: customer.latest_message_time || customer.time,
avatarUrl: customer.customer_avatar_url || customer.avatar,
avatar: customer.customer_avatar_url || customer.avatar || '/default-avatar.svg',
type: customer.type,
classNum: customer.class_num,
class_num: customer.class_num, // 确保字段名一致
salesStage: customer.type || stage,
priority: customer.type === '待联系' ? 'high' : 'normal',
time: customer.latest_message_time || customer.time || '未知',
health: customer.health || 75,
// 保留原始数据
customer_name: customer.customer_name,
customer_occupation: customer.customer_occupation,
customer_child_education: customer.customer_child_education,
scrm_user_main_code: customer.scrm_user_main_code,
weChat_avatar: customer.weChat_avatar,
class_situation: customer.class_situation,
records: customer.records
}));
// 更新当前筛选的客户数据
currentFilteredCustomers.value = filteredContacts;
} else if (extraData && extraData.isCourseStage) {
// 处理课程阶段的数据课1-4、课1、课2、课3、课4
const courseContacts = extraData.courseData.map(customer => ({
id: customer.id,
name: customer.name,
time: customer.time,
profession: customer.profession,
education: customer.education,
avatar: customer.avatar,
type: customer.type || stage, // 使用当前选中的阶段作为type
salesStage: customer.type || stage, // 使用customer.type或当前阶段作为salesStage
health: customer.health,
customer_name: customer.customer_name,
customer_occupation: customer.customer_occupation,
customer_child_education: customer.customer_child_education,
scrm_user_main_code: customer.scrm_user_main_code,
weChat_avatar: customer.weChat_avatar,
class_situation: customer.class_situation,
class_num: customer.class_num, // 添加class_num字段
pay_status: customer.pay_status, // 添加pay_status字段
records: customer.records
}));
currentFilteredCustomers.value = courseContacts;
} else {
// 如果没有额外数据,清空筛选结果,使用默认逻辑
currentFilteredCustomers.value = [];
}
};
// 处理子时间轴阶段选择
const handleSubStageSelect = (eventData) => {
console.log('子时间轴选择事件:', eventData);
// 将筛选后的客户数据转换为contacts格式
const filteredContacts = eventData.filteredCustomers.map(customer => ({
id: customer.customer_name || customer.id,
name: customer.customer_name || customer.name,
phone: customer.phone,
profession: customer.customer_occupation || customer.profession,
education: customer.customer_child_education || customer.education,
lastMessageTime: customer.latest_message_time || customer.time,
avatarUrl: customer.customer_avatar_url || customer.avatar,
avatar: customer.customer_avatar_url || customer.avatar || '/default-avatar.svg',
type: customer.type || eventData.originalStageType,
classNum: customer.class_num,
class_num: customer.class_num,
salesStage: eventData.stageType,
priority: customer.type === '待联系' ? 'high' : 'normal',
time: customer.latest_message_time || customer.time || '未知',
health: customer.health || 75,
// 保留原始数据
customer_name: customer.customer_name,
customer_occupation: customer.customer_occupation,
customer_child_education: customer.customer_child_education,
scrm_user_main_code: customer.scrm_user_main_code,
weChat_avatar: customer.weChat_avatar,
class_situation: customer.class_situation,
records: customer.records
}));
// 更新当前筛选的客户数据但保持selectedStage不变保持子时间轴显示
currentFilteredCustomers.value = filteredContacts;
console.log(`已筛选出${eventData.originalStageType}阶段的${filteredContacts.length}位客户`);
};
const handleViewFormData = async (contact) => {
// 获取客户表单数据
await getCustomerForm();
console.log('表单数据已加载:', formInfo.value);
};
const handleViewChatData = async (contact) => {
console.log('查看聊天数据:', contact)
await getCustomerChatInfo({
customerId: selectedContact.value?.customerId || 1
})
console.log('聊天数据已更新:', chatRecords.value)
};
const handleViewCallData = (contact) => {
// TODO: 实现通话录音查看逻辑
};
// 处理SOP分析事件
const handleAnalyzeSop = (analyzeData) => {
console.log('收到SOP分析请求:', analyzeData);
if (customerDetailRef.value && analyzeData.content) {
customerDetailRef.value.startSopAnalysis(analyzeData.content);
}
};
// 销售漏斗
const SalesFunnel = ref([])
async function CenterGetSalesFunnel() {
const params = getRequestParams()
const hasParams = params.user_name
const res = await withCache('getSalesFunnel', () => getSalesFunnel(hasParams ? params : undefined), hasParams ? params : {})
if(res.code === 200){
SalesFunnel.value = res.data
/**
* "data": {
"user_name": "常琳",
"user_level": 1,
"sale_funnel": {
"线索总数": 11,
"有效沟通": 9,
"到课数据": 8,
"预付定金": 0,
"成功签单": 0
}
}
*/
}
}
// 黄金联络时间段
const goldContactTime = ref([])
async function CenterGetGoldContactTime() {
const params = getRequestParams()
const hasParams = params.user_name
const res = await withCache('getGoldContactTime', () => getGoldContactTime(hasParams ? params : undefined), hasParams ? params : {})
if(res.code === 200){
goldContactTime.value = res.data
}
}
// 缓存管理功能
// 清除所有缓存
function clearCache() {
cache.clear()
console.log('所有缓存已清除')
}
// 清除特定缓存
function clearSpecificCache(apiName, params = {}) {
const key = getCacheKey(apiName, params)
cache.delete(key)
console.log(`已清除缓存: ${key}`)
}
// 获取缓存信息并清理过期缓存
function getCacheInfo() {
const now = Date.now()
const validCaches = []
const expiredCaches = []
for (const [key, data] of cache.entries()) {
if (isValidCache(data)) {
validCaches.push({
key,
timestamp: data.timestamp,
age: Math.round((now - data.timestamp) / 1000) + 's'
})
} else {
expiredCaches.push(key)
cache.delete(key)
}
}
console.log('有效缓存:', validCaches)
console.log('已清理过期缓存:', expiredCaches)
return {
validCount: validCaches.length,
expiredCount: expiredCaches.length,
validCaches,
expiredCaches
}
}
// 强制刷新所有数据清除缓存并重新调用所有API
async function forceRefreshAllData() {
console.log('开始强制刷新所有数据...')
clearCache()
// 重新调用所有API
await Promise.all([
getCoreKpi(),
getStatisticsData(),
getUrgentProblem(),
getTimeline(),
getCustomerPayMoney(),
CenterGetSalesFunnel(),
CenterGetGoldContactTime(),
// 客户相关数据需要在选中客户后才能获取
selectedContact.value ? getCustomerForm() : Promise.resolve(),
selectedContact.value ? getCustomerChat() : Promise.resolve(),
selectedContact.value ? getCustomerCall() : Promise.resolve()
])
console.log('所有数据刷新完成')
}
// LIFECYCLE HOOKS
onMounted(async () => {
try {
// 输出缓存状态信息
console.log('Sale页面缓存系统已初始化缓存时长:', CACHE_DURATION / (1000 * 60), '分钟')
isPageLoading.value = true
await getCoreKpi()
await CenterGetGoldContactTime()
await CenterGetSalesFunnel()
await getCustomerForm()
await getCustomerChat()
await getUrgentProblem()
await getCustomerCall()
await getTimeline()
await getCustomerPayMoney()
// 输出初始缓存信息
getCacheInfo()
// 开发环境下暴露缓存管理函数到全局对象,方便调试
if (process.env.NODE_ENV === 'development') {
window.saleCache = {
clearCache,
clearSpecificCache,
getCacheInfo,
forceRefreshAllData,
cache
}
console.log('开发模式:缓存管理函数已暴露到 window.saleCache')
}
// 等待数据加载完成后选择默认客户
await nextTick();
// 优先从API数据中选择第一个客户
if (formattedCustomersList.value.length > 0) {
selectedContactId.value = formattedCustomersList.value[0].id;
} else {
// 否则选择MOCK数据中的第一个高优先级联系人
const highPriorityContacts = MOCK_DATA.contacts.filter(
(contact) => contact.priority === "high"
);
if (highPriorityContacts.length > 0) {
selectedContactId.value = highPriorityContacts[0].id;
}
}
} catch (error) {
// 静默处理错误
} finally {
isPageLoading.value = false
}
});
</script>
<style lang="scss" scoped>
body {
margin: 0;
padding: 0;
}
// Color Variables
$slate-50: #f8fafc;
$slate-100: #f1f5f9;
$slate-200: #e2e8f0;
$slate-300: #cbd5e1;
$slate-400: #94a3b8;
$slate-500: #64748b;
$slate-600: #475569;
$slate-700: #334155;
$slate-800: #1e293b;
$white: #ffffff;
$primary: #3b82f6;
// 响应式混合器
@mixin mobile {
@media (max-width: 768px) {
@content;
}
}
@mixin tablet {
@media (max-width: 1023px) and (min-width: 769px) {
@content;
}
}
@mixin desktop {
@media (min-width: 1024px) {
@content;
}
}
.sales-dashboard {
background: #ffffff;
min-height: 100vh;
color: $slate-800;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
box-sizing: border-box;
font-size: 16px;
line-height: 1.6;
@include mobile {
font-size: 14px;
line-height: 1.5;
}
*, *::before, *::after {
box-sizing: border-box;
}
}
// 主要布局
.main-layout {
width: 100vw;
margin: 0 auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
@include desktop {
gap: 1.5rem;
}
@include tablet {
width: 90vw;
}
@include mobile {
width: 95vw;
padding: 0.75rem;
gap: 0.75rem;
}
@media (max-width: 480px) {
width: 98vw;
padding: 0.5rem;
gap: 0.5rem;
}
}
// 智能任务清单(原左侧边栏,现在在上方)
.sidebar {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 1rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
overflow: hidden;
height: fit-content;
max-height: 400px;
display: flex;
flex-direction: column;
@include desktop {
max-height: 450px;
}
@include tablet {
border-radius: 0.875rem;
max-height: 420px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
}
@include mobile {
border-radius: 0.75rem;
max-height: calc(100vh - 120px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.06);
}
@media (max-width: 480px) {
border-radius: 0.5rem;
max-height: calc(100vh - 100px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
}
.sidebar-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.1),
rgba(118, 75, 162, 0.1)
);
display: flex;
align-items: center;
justify-content: space-between;
@include desktop {
padding: 1.75rem;
}
@include mobile {
padding: 1.25rem;
}
@media (max-width: 480px) {
padding: 1rem;
}
h2 {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
font-weight: 600;
color: $slate-800;
@include desktop {
font-size: 1.25rem;
}
@include mobile {
font-size: 1rem;
}
@media (max-width: 480px) {
font-size: 0.9375rem;
}
}
.task-summary {
display: flex;
align-items: baseline;
gap: 0.25rem;
.task-count {
font-size: 1.5rem;
font-weight: 700;
color: #667eea;
@include desktop {
font-size: 1.75rem;
}
@include mobile {
font-size: 1.375rem;
}
@media (max-width: 480px) {
font-size: 1.25rem;
}
}
.task-label {
font-size: 0.875rem;
color: $slate-500;
@include desktop {
font-size: 0.9375rem;
}
@include mobile {
font-size: 0.8125rem;
}
}
}
}
.sidebar-content {
flex: 1;
overflow-y: auto;
}
}
// 主要内容区域
.main-content {
display: flex;
flex-direction: column;
gap: 2rem;
@include desktop {
gap: 2.5rem;
}
@include mobile {
gap: 1.5rem;
}
@media (max-width: 480px) {
gap: 1rem;
}
.detail-section {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 1rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
overflow: hidden;
@include tablet {
border-radius: 0.875rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
}
@include mobile {
border-radius: 0.75rem;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.06);
}
@media (max-width: 480px) {
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
}
.section-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.05),
rgba(118, 75, 162, 0.05)
);
@include desktop {
padding: 1.75rem;
flex-direction: row;
align-items: center;
gap: 2rem;
}
@include tablet {
flex-direction: row;
align-items: center;
gap: 1.5rem;
}
@include mobile {
padding: 1.25rem;
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
@media (max-width: 480px) {
padding: 1rem;
gap: 0.75rem;
}
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: $slate-800;
@include desktop {
font-size: 1.25rem;
}
@include mobile {
font-size: 1rem;
}
@media (max-width: 480px) {
font-size: 0.9375rem;
}
}
.section-actions {
display: flex;
gap: 0.75rem;
// PC端保持一致布局
@media (min-width: 1024px) {
gap: 1rem;
flex-wrap: nowrap;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
gap: 0.75rem;
flex-wrap: nowrap;
}
// 移动端适配
@media (max-width: 768px) {
gap: 0.5rem;
width: 100%;
justify-content: flex-start;
flex-wrap: nowrap;
}
// 小屏移动端适配
@media (max-width: 480px) {
gap: 0.375rem;
flex-wrap: wrap;
}
.action-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
touch-action: manipulation;
white-space: nowrap;
// PC端保持一致布局
@media (min-width: 1024px) {
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
// 移动端适配
@media (max-width: 768px) {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
// 小屏移动端适配
@media (max-width: 480px) {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
min-width: 60px;
}
&.primary {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
}
&.secondary {
background: $white;
color: $slate-700;
border: 1px solid $slate-300;
&:hover {
background: $slate-50;
border-color: $slate-400;
}
}
}
}
.view-toggle {
display: flex;
background: $slate-100;
border-radius: 0.5rem;
padding: 0.25rem;
.toggle-btn {
padding: 0.5rem 1rem;
border: none;
background: transparent;
color: $slate-600;
font-size: 0.875rem;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
&.active {
background: $white;
color: $slate-800;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
&:hover:not(.active) {
color: $slate-800;
}
}
}
}
.section-content {
padding: 0.5rem;
@media (max-width: 768px) {
padding: 1.25rem;
}
@media (max-width: 480px) {
padding: 1rem;
}
}
}
.detail-section {
flex: 0 0 auto;
}
}
// 销售漏斗和时间线区域样式
.funnel-section,
.timeline-section,
.raw-data-section {
grid-column: 1 / -1;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 1rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
overflow: hidden;
margin-bottom: 1.5rem;
width: 100%;
@include tablet {
border-radius: 0.875rem;
margin-bottom: 1.25rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
}
@include mobile {
border-radius: 0.75rem;
margin-bottom: 1rem;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.06);
}
@media (max-width: 480px) {
border-radius: 0.5rem;
margin-bottom: 0.75rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
}
.section-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.05),
rgba(118, 75, 162, 0.05)
);
@media (max-width: 768px) {
padding: 1.25rem;
}
@media (max-width: 480px) {
padding: 1rem;
}
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: $slate-800;
@media (max-width: 768px) {
font-size: 1rem;
}
@media (max-width: 480px) {
font-size: 0.9375rem;
}
}
.section-subtitle {
margin: 0.5rem 0 0 0;
font-size: 0.875rem;
color: $slate-600;
font-weight: 400;
@media (max-width: 768px) {
font-size: 0.8125rem;
}
@media (max-width: 480px) {
font-size: 0.75rem;
}
}
}
.section-content {
padding: 1.5rem;
@media (max-width: 768px) {
padding: 1.25rem;
}
@media (max-width: 480px) {
padding: 1rem;
}
}
}
// 独立的数据分析区域 - 跨越整个宽度
.analytics-section-full {
grid-column: 1 / -1;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 1rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
overflow: hidden;
width: 100%;
min-height: 400px;
@include desktop {
min-height: 150px;
}
@include tablet {
min-height: 380px;
border-radius: 0.875rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
}
@include mobile {
border-radius: 0.75rem;
min-height: 320px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.06);
}
@media (max-width: 480px) {
border-radius: 0.5rem;
min-height: 280px;
margin-top: 0.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
}
.section-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.05),
rgba(118, 75, 162, 0.05)
);
// 路由导航顶栏样式
.route-header {
width: 100%;
padding: 0 2rem;
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
.breadcrumb-item {
color: $slate-600;
font-size: 0.875rem;
font-weight: 500;
&:not(.current) {
cursor: pointer;
transition: color 0.2s;
&:hover {
color: $primary;
}
}
&.current {
color: $slate-800;
font-weight: 600;
}
}
.breadcrumb-separator {
color: $slate-400;
font-size: 0.875rem;
}
}
.user-name {
color: $slate-800;
font-size: 1.125rem;
font-weight: 600;
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.8);
border-radius: 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.1);
}
}
// PC端保持一致布局
@media (min-width: 1024px) {
padding: 1.75rem;
flex-direction: row;
align-items: center;
gap: 2rem;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
padding: 1.5rem;
flex-direction: row;
align-items: center;
gap: 1.5rem;
}
// 移动端适配
@media (max-width: 768px) {
padding: 1.25rem;
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
// 小屏移动端适配
@media (max-width: 480px) {
padding: 1rem;
gap: 0.75rem;
}
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: $slate-800;
@media (max-width: 768px) {
font-size: 1rem;
}
@media (max-width: 480px) {
font-size: 0.9375rem;
}
}
.view-toggle {
display: flex;
background: $slate-100;
border-radius: 0.5rem;
padding: 0.25rem;
@include desktop {
width: auto;
flex-wrap: nowrap;
justify-content: flex-start;
}
@include tablet {
width: auto;
flex-wrap: nowrap;
justify-content: flex-start;
}
@include mobile {
width: 100%;
justify-content: space-between;
flex-wrap: nowrap;
}
@media (max-width: 480px) {
flex-wrap: wrap;
gap: 0.25rem;
justify-content: flex-start;
}
.toggle-btn {
padding: 0.5rem 1rem;
border: none;
background: transparent;
color: $slate-600;
font-size: 0.875rem;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
touch-action: manipulation;
white-space: nowrap;
@include desktop {
padding: 0.5rem 1.25rem;
flex: none;
}
@include tablet {
padding: 0.5rem 1rem;
flex: none;
}
@include mobile {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
flex: 1;
}
@media (max-width: 480px) {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
min-width: 60px;
flex: none;
}
&.active {
background: $white;
color: $slate-800;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
&:hover:not(.active) {
color: $slate-800;
}
}
}
}
.section-content {
padding: 1.5rem;
@include desktop {
padding: 1.75rem;
}
@include mobile {
padding: 1.25rem;
}
@media (max-width: 480px) {
padding: 1rem;
}
}
// 局部加载状态样式
.section-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
min-height: 200px;
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #e2e8f0;
border-top: 3px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
.loading-text {
color: #64748b;
font-size: 0.9rem;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
}
</style>