Files
DJKB/my-vue-app/src/views/person/sale.vue
lbw_9527443 8bd8a9145f refactor(销售时间线): 优化客户数据管理和展示逻辑
重构销售时间线组件,分离不同阶段的客户数据管理:
1. 不再将后三个阶段和成交阶段的客户合并到customersList
2. 为各阶段客户数据添加独立处理逻辑
3. 优化课程显示逻辑,优先使用class_num字段
4. 简化总数计算逻辑
2025-08-13 20:56:27 +08:00

1428 lines
38 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">
<!-- 悬浮待办组件 -->
<FloatingTodo />
<!-- 页面加载状态 -->
<Loading :visible="isPageLoading" text="正在加载数据..." />
<!-- 顶部导航栏 -->
<!-- 数据分析区域 - 独立占据整行 -->
<section class="analytics-section-full">
<div class="section-header">
<h1 class="app-title">销售驾驶舱</h1>
<div
class="quick-stats"
style="display: flex; align-items: center; gap: 30px"
>
<div
v-for="(stat, key) in MOCK_DATA.performance"
:key="key"
class="quick-stat-item"
>
<div class="stat-label">
{{ stat.label + ":" + stat.value.toLocaleString() }}
</div>
</div>
</div>
<UserDropdown />
</div>
<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="contactTimeData"
:statistics-data="statisticsData"
:urgent-problem-data="urgentProblemData"
/>
</div>
</section>
<!-- 销售时间线区域 -->
<section class="timeline-section">
<div class="section-header">
<h2>销售时间线</h2>
<p class="section-subtitle">客户转化全流程跟踪</p>
</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"
@view-form-data="handleViewFormData"
@view-chat-data="handleViewChatData"
@view-call-data="handleViewCallData" />
</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 :selected-contact="selectedContact" />
</div>
</section>
</main>
</div>
</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 FloatingTodo from "./components/FloatingTodo.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} 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
}
// STATE
const selectedContactId = ref(null);
const contextPanelRef = 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);
// 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 problemDistribution = res.data.problem_distribution
urgentProblemData.value = Object.entries(problemDistribution).map(([name, percentage]) => ({
name: name,
value: parseInt(percentage.replace('%', '')) || 0
}))
}
} 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) {
MOCK_DATA.formFields = res.data
}
} 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(() => ({
labels: ["线索", "沟通", "意向", "预约", "成交"],
data: MOCK_DATA.personalFunnel,
}));
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();
}
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 = (contact) => {
// TODO: 实现表单数据查看逻辑
};
const handleViewChatData = (contact) => {
// TODO: 实现聊天记录查看逻辑
};
const handleViewCallData = (contact) => {
// TODO: 实现通话录音查看逻辑
};
// LIFECYCLE HOOKS
onMounted(async () => {
try {
isPageLoading.value = true
await getTimeline()
// 等待数据加载完成后选择默认客户
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: 450px;
}
@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)
);
// 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>