1799 lines
50 KiB
Vue
1799 lines
50 KiB
Vue
<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>
|