Files
DJKB/my-vue-app/src/views/person/sale.vue
lbw_9527443 f1fe585fc4 refactor(person/sale): 简化紧急问题数据转换逻辑
移除百分比转换步骤,直接使用API返回的数值格式
2025-08-25 11:05:24 +08:00

1611 lines
44 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"
: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} from "@/api/api.js"
// 路由实例
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 getTodayCall(hasParams ? params : undefined)
if (res.code === 200) {
kpiDataState.totalCalls = res.data.today_call
}
// 转化率、分配数据量、加微率
const conversionRes = await getConversionRateAndAllocatedData(hasParams ? params : undefined)
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
}
} 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 getTableFillingRate(hasParams ? params : undefined)
if (fillingRateRes.code === 200) {
statisticsData.formCompletionRate = fillingRateRes.data.filling_rate
}
// 获取平均响应时间
const avgResponseRes = await getAverageResponseTime(hasParams ? params : undefined)
if (avgResponseRes.code === 200) {
statisticsData.averageResponseTime = avgResponseRes.data.average_minutes
}
// 获取客户沟通率
const communicationRes = await getWeeklyActiveCommunicationRate(hasParams ? params : undefined)
if (communicationRes.code === 200) {
statisticsData.customerCommunicationRate = communicationRes.data.communication_rate
}
// 获取超时响应率
const timeoutRes = await getTimeoutResponseRate(hasParams ? params : undefined)
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 getProblemDistribution(hasParams ? params : undefined)
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 getCustomerAttendance(hasParams ? params : undefined)
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 getCustomerAttendanceAfterClass4(hasParams ? params : undefined)
if(classRes.code === 200) {
// 处理课1-4阶段的客户数据
if (classRes.data.class_customers_list) {
// 存储课1-4阶段的原始数据根据pay_status设置正确的type
courseCustomers.value['课1-4'] = classRes.data.class_customers_list.map(customer => {
let customerType = '课1-4'; // 默认类型
// 根据pay_status设置具体的type
if (customer.pay_status === '未支付' || 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 getPayMoneyCustomers(hasParams ? params : undefined)
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 getCustomerFormInfo(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 getCustomerChatInfo(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 getCustomerCallInfo(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 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阶段的课程数据保持原有逻辑
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 || '课1-4', // 保持原有type字段如果没有则默认为课1-4
salesStage: customer.type || '课1-4', // 使用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 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 getSalesFunnel(hasParams?params:undefined)
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 getGoldContactTime(hasParams?params:undefined)
if(res.code === 200){
goldContactTime.value = res.data
}
}
// LIFECYCLE HOOKS
onMounted(async () => {
try {
isPageLoading.value = true
await getCoreKpi()
await CenterGetGoldContactTime()
await CenterGetSalesFunnel()
await getCustomerForm()
await getCustomerChat()
await getUrgentProblem()
await getCustomerCall()
await getTimeline()
await getCustomerPayMoney()
// 等待数据加载完成后选择默认客户
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>