Files
DJKB/my-vue-app/src/views/person/components/SalesTimelineWithTaskList.vue
lbw_9527443 d5792be702 feat(销售时间轴): 添加time_and_camp_stage字段支持并优化课程阶段筛选逻辑
- 在客户数据中添加time_and_camp_stage字段用于记录付款行为发生的课程阶段
- 重构课程阶段筛选逻辑,优先使用time_and_camp_stage字段进行精确匹配
- 移除已注释的旧代码
- 更新API基础路径为生产环境
2025-09-01 21:14:52 +08:00

1699 lines
47 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-timeline-with-task-list">
<div class="timeline-title-section" style="display: flex; align-items: center; gap: 10px;">
<h2 style="font-size: 18px;margin: 0;">销售时间线</h2>
<span style="font-size: 14px;">客户转化全流程跟踪</span>
</div>
<div class="sales-timeline">
<div class="timeline-container">
<div class="timeline-line"></div>
<div
v-for="(stage, index) in stages"
:key="stage.id"
class="timeline-stage"
:class="{ 'active': stage.count > 0, 'selected': selectedStage === stage.name }"
@click="selectStage(stage.name)"
>
<div class="stage-marker">
<div class="marker-circle">
<span class="stage-number">{{ index + 1 }}</span>
</div>
<div class="marker-line" v-if="index < stages.length - 1"></div>
</div>
<div class="stage-content">
<h3 class="stage-title">{{ stage.displayName || stage.name }}</h3>
<div class="stage-stats">
<span class="stage-count">{{ stage.name === '全部' ? customersCount : stage.count }}</span>
<span class="stage-label"></span>
</div>
<div class="stage-unName">
<span class="stage-unName" v-if="stage.unName">{{ stage.unName }}</span>
</div>
<div v-if="stage.name !== '全部'" class="stage-percentage">{{ getPercentage(stage.count, stage.id, stage.name) }}%</div>
</div>
</div>
</div>
</div>
<!-- 课1-4子时间轴 -->
<div v-if="selectedStage === '课1-4'" class="course-sub-timeline">
<div class="sub-timeline-container">
<!-- 课1子时间轴 -->
<div class="course-timeline">
<div class="course-header">
<span class="course-title">课1</span>
<span class="course-conversion">转化率: {{ getCourseConversionRate(1) }}%</span>
</div>
<div class="mini-timeline">
<div class="mini-stage" :class="{ active: getCourseStageCount(1, '课1') > 0 }" @click="selectCourseStage(1, '课1')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">课1</span>
<span class="mini-count">{{ getCourseStageCount(1, '课1') }}</span>
</div>
</div>
<div class="mini-stage" :class="{ active: getCourseStageCount(1, '付定金') > 0 }" @click="selectCourseStage(1, '付定金')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">付定金</span>
<span class="mini-count">{{ getCourseStageCount(1, '付定金') }}</span>
</div>
</div>
</div>
</div>
<!-- 课2子时间轴 -->
<div class="course-timeline">
<div class="course-header">
<span class="course-title">课2</span>
</div>
<div class="mini-timeline">
<div class="mini-stage" :class="{ active: getCourseStageCount(2, '课2') > 0 }" @click="selectCourseStage(2, '课2')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">课2</span>
<span class="mini-count">{{ getCourseStageCount(2, '课2') }}</span>
</div>
</div>
<div class="mini-stage" :class="{ active: getCourseStageCount(2, '点击未支付') > 0 }" @click="selectCourseStage(2, '点击未支付')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">点击支付</span>
<span class="mini-count">{{ getCourseStageCount(2, '点击未支付') }}</span>
</div>
</div>
<div class="mini-stage" :class="{ active: getCourseStageCount(2, '付定金') > 0 }" @click="selectCourseStage(2, '付定金')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">付定金</span>
<span class="mini-count">{{ getCourseStageCount(2, '付定金') }}</span>
</div>
</div>
<div class="mini-stage" :class="{ active: getCourseStageCount(2, '定金转化') > 0 }" @click="selectCourseStage(2, '定金转化')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">定金转化</span>
<span class="mini-count">{{ getCourseStageCount(2, '定金转化') }}</span>
</div>
</div>
<div class="mini-stage" :class="{ active: getCourseStageCount(2, '成交') > 0 }" @click="selectCourseStage(2, '成交')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">成交</span>
<span class="mini-count">{{ getCourseStageCount(2, '成交') }}</span>
</div>
</div>
</div>
</div>
<!-- 课3子时间轴 -->
<div class="course-timeline">
<div class="course-header">
<span class="course-title">课3</span>
</div>
<div class="mini-timeline">
<div class="mini-stage" :class="{ active: getCourseStageCount(3, '课3') > 0 }" @click="selectCourseStage(3, '课3')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">课3</span>
<span class="mini-count">{{ getCourseStageCount(3, '课3') }}</span>
</div>
</div>
<div class="mini-stage" :class="{ active: getCourseStageCount(3, '点击未支付') > 0 }" @click="selectCourseStage(3, '点击未支付')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">点击未付</span>
<span class="mini-count">{{ getCourseStageCount(3, '点击未支付') }}</span>
</div>
</div>
<div class="mini-stage" :class="{ active: getCourseStageCount(3, '付定金') > 0 }" @click="selectCourseStage(3, '付定金')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">付定金</span>
<span class="mini-count">{{ getCourseStageCount(3, '付定金') }}</span>
</div>
</div>
<div class="mini-stage" :class="{ active: getCourseStageCount(3, '定金转化') > 0 }" @click="selectCourseStage(3, '定金转化')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">定金转化</span>
<span class="mini-count">{{ getCourseStageCount(3, '定金转化') }}</span>
</div>
</div>
<div class="mini-stage" :class="{ active: getCourseStageCount(3, '成交') > 0 }" @click="selectCourseStage(3, '成交')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">成交</span>
<span class="mini-count">{{ getCourseStageCount(3, '成交') }}</span>
</div>
</div>
</div>
</div>
<!-- 课4子时间轴 -->
<div class="course-timeline">
<div class="course-header">
<span class="course-title">课4</span>
</div>
<div class="mini-timeline">
<div class="mini-stage" :class="{ active: getCourseStageCount(4, '课4') > 0 }" @click="selectCourseStage(4, '课4')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">课4</span>
<span class="mini-count">{{ getCourseStageCount(4, '课4') }}</span>
</div>
</div>
<div class="mini-stage" :class="{ active: getCourseStageCount(4, '点击未支付') > 0 }" @click="selectCourseStage(4, '点击未支付')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">点击未付</span>
<span class="mini-count">{{ getCourseStageCount(4, '点击未支付') }}</span>
</div>
</div>
<div class="mini-stage" :class="{ active: getCourseStageCount(4, '付定金') > 0 }" @click="selectCourseStage(4, '付定金')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">付定金</span>
<span class="mini-count">{{ getCourseStageCount(4, '付定金') }}</span>
</div>
</div>
<div class="mini-stage" :class="{ active: getCourseStageCount(4, '定金转化') > 0 }" @click="selectCourseStage(4, '定金转化')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">定金转化</span>
<span class="mini-count">{{ getCourseStageCount(4, '定金转化') }}</span>
</div>
</div>
<div class="mini-stage" :class="{ active: getCourseStageCount(4, '成交') > 0 }" @click="selectCourseStage(4, '成交')">
<div class="mini-marker"></div>
<div class="mini-content">
<span class="mini-title">成交</span>
<span class="mini-count">{{ getCourseStageCount(4, '成交') }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="task-body">
<div class="actionable-list">
<div class="items-grid">
<div
v-for="item in contacts"
:key="item.id"
:id="`contact-item-${item.id}`"
class="action-item"
:class="[getHealthClass(item.health), { 'active': item.id === selectedContactId }]"
@click="selectContact(item.id)">
<div class="item-content">
<!-- 头像区域 -->
<div class="avatar-section">
<div class="avatar">
<img :src="item.avatar || '/default-avatar.svg'" :alt="item.name" class="avatar-img" />
</div>
</div>
<!-- 信息区域 -->
<div class="info-section">
<div class="item-header">
<div class="item-name-group">
<span class="item-name" :title="item.name">{{ item.name }}</span>
</div>
<span class="item-time">{{ item.time }}</span>
</div>
<div class="item-footer">
<div class="profession-education">
<span class="profession">{{ item.profession||'未知' }}</span>
<span class="education">{{ item.education||'未知' }}</span>
<span class="course-attendance">
{{ getAttendedLessons(item.class_situation,item.class_num) }}
</span>
<span class="course-type" v-if="item.type">{{ item.type }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 到课详情区域 -->
<div v-if="selectedContactId && selectedContactDetails && (['课1-4', '课1', '课2', '课3', '课4'].includes(selectedStage))" class="course-details">
<div class="course-details-header">
<h4>{{ selectedContactDetails.name }} - 到课详情</h4>
</div>
<div class="course-details-content">
<div v-if="!selectedContactDetails.class_situation || Object.keys(selectedContactDetails.class_situation).length === 0" class="no-data">
未到课
</div>
<div v-else class="course-lessons">
<div
v-for="(lessonData, lessonNum) in selectedContactDetails.class_situation"
:key="lessonNum"
class="lesson-item"
>
<div class="lesson-header">
<span class="lesson-number">{{ lessonNum }}节课</span>
</div>
<div class="lesson-details">
<div class="detail-item">
<span class="detail-label">听课时长:</span>
<span class="detail-value">{{ Math.round((lessonData.live_maximum_length_time || 0) / 60) }}分钟</span>
</div>
<div class="detail-item">
<span class="detail-label">是否看回放:</span>
<span class="detail-value">{{ lessonData.playback_maximum_length_time ? '是' : '否' }}</span>
</div>
<div class="detail-item" v-if="lessonData.playback_maximum_length_time">
<span class="detail-label">回放时长:</span>
<span class="detail-value">{{ Math.round((lessonData.playback_maximum_length_time || 0) / 60) }}分钟</span>
</div>
<div class="detail-item" v-if="lessonData.speak_message">
<span class="detail-label">直播发言:</span>
<span class="detail-value clickable" @click="showSpeakMessages(lessonData.speak_message)">{{ lessonData.speak_message.length || 0 }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 发言内容弹窗 -->
<div v-if="showModal" class="modal-overlay" @click="closeModal">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>直播发言内容</h3>
<button class="close-btn" @click="closeModal">×</button>
</div>
<div class="modal-body">
<div v-if="!modalMessages || modalMessages.length === 0" class="no-messages">
暂无发言内容
</div>
<div v-else class="messages-list">
<div v-for="(message, index) in modalMessages" :key="index" class="message-item">
<span class="message-number">{{ index + 1 }}.</span>
<span class="message-content">{{ message }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
// 弹窗相关的响应式数据
const showModal = ref(false);
const modalMessages = ref([]);
// 定义props
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
selectedStage: {
type: String,
default: 'all'
},
contacts: {
type: Array,
required: true
},
selectedContactId: {
type: Number,
default: null
},
courseCustomers: {
type: Object,
default: () => ({})
},
customersList: {
type: Array,
default: () => []
},
customersCount: {
type: Number,
default: 0
},
payMoneyCustomersList: {
type: Array,
default: () => []
},
payMoneyCustomersCount: {
type: Number,
default: 0
}
});
const emit = defineEmits(['stage-select', 'select-contact']);
const totalCustomers = computed(() => {
// 全部阶段的数量就是前6个阶段的数量
if (props.customersList?.length > 0) return props.customersList.length;
// 如果没有实际数据使用props.data中的数据作为后备
if (props.data['全部']) return props.data['全部'];
const baseStages = [
props.data['未加微'] || 0,
props.data['已加微'] || 0,
props.data['已入群'] || 0,
props.data['已填表单'] || 0,
props.data['课1-4'] || 0,
props.data['点击未支付'] || 0,
props.data['付定金'] || 0,
props.data['定价转化'] || 0,
props.data['成交'] || 0
];
return Math.max(...baseStages, 1);
});
const getStageCount = (stageType) => {
// 成交阶段从payMoneyCustomersList获取数量
if (stageType === '成交') {
return props.payMoneyCustomersCount || (props.payMoneyCustomersList?.length || 0);
}
// 课1-4阶段从courseCustomers获取数量
if (stageType === '课1-4' && props.courseCustomers?.['课1-4']) {
return props.courseCustomers['课1-4'].length;
}
// 后3个阶段从courseCustomers中筛选
if (['点击未支付', '付定金', '定金转化'].includes(stageType)) {
if (props.courseCustomers?.['课1-4']) {
return props.courseCustomers['课1-4'].filter(customer => customer.type === stageType).length;
}
return 0;
}
// 待填表单阶段特殊处理筛选customer_occupation或customer_child_education字段为'未知'的客户
if (stageType === '待填表单') {
if (props.customersList?.length) {
return props.customersList.filter(customer => {
return (customer.customer_occupation === '未知' || !customer.customer_occupation) ||
(customer.customer_child_education === '未知' || !customer.customer_child_education);
}).length;
}
return 0;
}
// 待到课阶段特殊处理:筛选没有到课数据的客户
if (stageType === '待到课') {
if (props.customersList?.length) {
return props.customersList.filter(customer => {
const classNum = customer.class_num;
const classSituation = customer.class_situation;
// 优先检查class_num字段
if (classNum && Array.isArray(classNum) && classNum.length > 0) {
return false; // 有class_num数据不是待到课
}
// 检查class_situation字段
if (!classSituation) return true; // 没有class_situation是待到课
if (Array.isArray(classSituation)) {
return classSituation.length === 0; // 空数组,是待到课
}
if (typeof classSituation === 'object') {
const lessonNumbers = Object.keys(classSituation)
.map(key => parseInt(key))
.filter(num => !isNaN(num));
return lessonNumbers.length === 0; // 没有有效的课程数据,是待到课
}
return true; // 其他情况默认为待到课
}).length;
}
return 0;
}
// 前6个阶段从customersList中筛选
if (props.customersList?.length) {
return props.customersList.filter(customer => customer.type === stageType).length;
}
// 如果没有数据使用props.data中的数据
return props.data[stageType] || 0;
};
const stages = computed(() => {
// 全部阶段的数量就是前6个阶段的数量
const totalCount = props.customersList?.length || 0;
const stageList = [
{ id: 0, name: '全部', displayName: '全部', count: totalCount, color: '#f3f4f6' },
{ id: 1, name: '待加微', displayName: '待加微', count: getStageCount('待加微'), color: '#e3f2fd' },
{ id: 2, name: '待填表单', displayName: '待填表单', count: getStageCount('待填表单'), color: '#90caf9' },
{ id: 3, name: '待入群', displayName: '待入群', count: getStageCount('待入群'), color: '#bbdefb' },
{ id: 4, name: '待联系', displayName: '待联系', count: getStageCount('待联系'), color: '#bbdefb' },
{ id: 5, name: '待到课', displayName: '待到课', count: getStageCount('待到课'), color: '#bbdefb'},
{ id: 6, name: '课1-4', displayName: '课1-4', count: getStageCount('课1-4'), color: '#81c784' },
{ id: 7, name: '点击未支付', displayName: '点击未付', count: getStageCount('点击未支付'), color: '#42a5f5' },
{ id: 8, name: '付定金', displayName: '付定金', count: getStageCount('付定金'), color: '#2196f3' },
{ id: 9, name: '定金转化', displayName: '定金转化', count: getStageCount('定金转化'), color: '#1e88e5' },
{ id: 10, name: '成交', displayName: '成交', count: getStageCount('成交'), color: '#1976d2' }
];
return stageList;
});
const getPercentage = (count, stageId, stageName) => {
if (totalCustomers.value === 0) return 0;
// 待填表单阶段特殊计算:(总数-有数据人数)/总数
if (stageName === '待填表单') {
return Math.round((totalCustomers.value - count) / totalCustomers.value * 100);
}
// 待入群阶段特殊计算:(总数-待加微人数-待入群人数)/总数
if (stageName === '待入群') {
const waitAddWechatCount = getStageCount('待加微');
const waitJoinGroupCount = getStageCount('待入群');
return Math.round((totalCustomers.value - waitAddWechatCount - waitJoinGroupCount) / totalCustomers.value * 100);
}
// 待联系阶段特殊计算:(总数-待加微人数-待联系人数)/总数
if (stageName === '待联系') {
const waitAddWechatCount = getStageCount('待加微');
const waitContactCount = getStageCount('待联系');
return Math.round((totalCustomers.value - waitAddWechatCount - waitContactCount) / totalCustomers.value * 100);
}
// 待到课阶段特殊计算:(总数-有到课数据人数)/总数
if (stageName === '待到课') {
return Math.round((totalCustomers.value - count) / totalCustomers.value * 100);
}
// 阶段1-5使用现在的算法转化率
if (stageId >= 1 && stageId <= 5) {
return Math.round((totalCustomers.value - count) / totalCustomers.value * 100);
}
// 阶段5-13使用直接数量除以总数
if (stageId >5 && stageId <= 13) {
return Math.round((count / totalCustomers.value) * 100);
}
// 其他阶段保持原有逻辑
return Math.round((totalCustomers.value - count) / totalCustomers.value * 100);
};
const selectStage = (stageName) => {
let filteredCustomers = [];
if (stageName === '全部') {
// 全部阶段只显示前6个阶段的客户数据
filteredCustomers = props.customersList || [];
} else if (stageName === '课1-4' && props.courseCustomers?.['课1-4']) {
emit('stage-select', stageName, {
isCourseStage: true,
courseData: props.courseCustomers['课1-4'],
filteredCustomers: props.courseCustomers['课1-4']
});
return;
} else if (stageName === '成交') {
// 成交阶段使用payMoneyCustomersList数据
if (props.payMoneyCustomersList && props.payMoneyCustomersList.length > 0) {
filteredCustomers = props.payMoneyCustomersList.map(customer => ({
customer_name: customer.customer_name,
phone: customer.phone,
customer_occupation: customer.customer_occupation,
customer_child_education: customer.customer_child_education,
latest_message_time: customer.latest_message_time,
customer_avatar_url: customer.customer_avatar_url,
type: '成交'
}));
}
} else if (['点击未支付', '付定金', '定金转化'].includes(stageName)) {
// 后3个阶段从courseCustomers中筛选
if (props.courseCustomers?.['课1-4']) {
filteredCustomers = props.courseCustomers['课1-4'].filter(customer => customer.type === stageName);
}
} else if (stageName === '待填表单') {
// 待填表单阶段筛选customer_occupation或customer_child_education字段为'未知'的客户
filteredCustomers = props.customersList.filter(customer => {
return (customer.customer_occupation === '未知' || !customer.customer_occupation) ||
(customer.customer_child_education === '未知' || !customer.customer_child_education);
});
} else if (stageName === '待到课') {
// 待到课阶段:筛选没有到课数据的客户
filteredCustomers = props.customersList.filter(customer => {
// 根据getAttendedLessons函数的逻辑判断是否为'未到课'
const classNum = customer.class_num;
const classSituation = customer.class_situation;
// 优先检查class_num字段
if (classNum && Array.isArray(classNum) && classNum.length > 0) {
return false; // 有class_num数据不是待到课
}
// 检查class_situation字段
if (!classSituation) return true; // 没有class_situation是待到课
if (Array.isArray(classSituation)) {
return classSituation.length === 0; // 空数组,是待到课
}
if (typeof classSituation === 'object') {
const lessonNumbers = Object.keys(classSituation)
.map(key => parseInt(key))
.filter(num => !isNaN(num));
return lessonNumbers.length === 0; // 没有有效的课程数据,是待到课
}
return true; // 其他情况默认为待到课
});
} else {
// 前6个阶段从customersList中筛选
filteredCustomers = props.customersList.filter(customer => customer.type === stageName);
}
emit('stage-select', stageName, {
filteredCustomers,
stageType: stageName,
customerCount: filteredCustomers.length
});
};
const selectedContactDetails = computed(() => {
if (!props.selectedContactId) return null;
return props.contacts.find(contact => contact.id === props.selectedContactId);
});
const selectContact = (id) => {
emit('select-contact', id);
};
// 显示发言内容弹框
const showSpeakMessages = (speakMessages) => {
modalMessages.value = speakMessages || [];
showModal.value = true;
};
const closeModal = () => {
showModal.value = false;
modalMessages.value = [];
};
// 获取课程阶段数量
const getCourseStageCount = (courseNumber, stageType) => {
if (!props.customersList && !props.courseCustomers) return 0;
// 根据课程号和阶段类型筛选客户
let filteredCustomers = [];
if (stageType === `${courseNumber}`) {
// 课程阶段:筛选有对应课程到课记录的客户
filteredCustomers = props.customersList.filter(customer => {
const classNum = customer.class_num;
const classSituation = customer.class_situation;
// 检查class_num字段
if (classNum && Array.isArray(classNum)) {
return classNum.includes(courseNumber);
}
// 检查class_situation字段
if (classSituation) {
if (Array.isArray(classSituation)) {
return classSituation.includes(courseNumber);
}
if (typeof classSituation === 'object') {
return classSituation.hasOwnProperty(courseNumber.toString());
}
}
return false;
});
} else {
// 其他阶段从courseCustomers['课1-4']中筛选
if (props.courseCustomers?.['课1-4']) {
filteredCustomers = props.courseCustomers['课1-4'].filter(customer => {
// 检查客户的付款行为是否发生在指定课程阶段并且类型匹配
let hasPaymentInCourse = false;
// 优先使用time_and_camp_stage字段判断付款行为发生的课程阶段
if (customer.time_and_camp_stage && Array.isArray(customer.time_and_camp_stage)) {
hasPaymentInCourse = customer.time_and_camp_stage.some(record => {
return record.camp_stage === `${courseNumber}` && record.pay_status === stageType;
});
}
// 如果time_and_camp_stage没有匹配的记录则回退到class_num字段
if (!hasPaymentInCourse) {
const hasAttendedCourse = customer.class_num && Array.isArray(customer.class_num) && customer.class_num.includes(courseNumber);
hasPaymentInCourse = hasAttendedCourse && customer.type === stageType;
}
return hasPaymentInCourse;
});
}
}
return filteredCustomers.length;
};
// 获取课程转化率
const getCourseConversionRate = (courseNumber) => {
const courseStageCount = getCourseStageCount(courseNumber, `${courseNumber}`);
const depositCount = getCourseStageCount(courseNumber, '付定金');
if (courseStageCount === 0) return 0;
return Math.round((depositCount / courseStageCount) * 100);
};
const getAttendedLessons = (classSituation, classNum) => {
// 优先使用 class_num 字段
if (classNum && Array.isArray(classNum) && classNum.length > 0) {
const filteredClassNum = classNum.filter(num => num !== -1);
return filteredClassNum.length > 0 ? filteredClassNum.sort((a, b) => a - b).join(' ') : '未到课';
}
// 如果没有 class_num则使用 class_situation
if (!classSituation) return '未到课';
if (Array.isArray(classSituation)) {
const filteredSituation = classSituation.filter(item => item !== -1);
return filteredSituation.length > 0 ? filteredSituation.join(' ') : '未到课';
}
if (typeof classSituation === 'object') {
const lessonNumbers = Object.keys(classSituation)
.map(key => parseInt(key))
.filter(num => !isNaN(num) && num !== -1)
.sort((a, b) => a - b);
return lessonNumbers.length > 0 ? lessonNumbers.join(' ') : '未到课';
}
return '未到课';
};
// 获取健康度对应的CSS类
const getHealthClass = (health) => {
if (!health && health !== 0) return '';
const healthValue = parseFloat(health);
if (healthValue >= 80) {
return 'health-good';
} else if (healthValue >= 60) {
return 'health-normal';
} else if (healthValue >= 40) {
return 'health-warning';
} else {
return 'health-danger';
}
};
// 选择课程阶段
const selectCourseStage = (courseNumber, stageType) => {
let filteredCustomers = [];
if (stageType === `${courseNumber}`) {
// 课程阶段从customersList中筛选
filteredCustomers = props.customersList.filter(customer => {
const classNum = customer.class_num;
const classSituation = customer.class_situation;
// 检查class_num字段
if (classNum && Array.isArray(classNum)) {
return classNum.includes(courseNumber);
}
// 检查class_situation字段
if (classSituation) {
if (Array.isArray(classSituation)) {
return classSituation.includes(courseNumber);
}
if (typeof classSituation === 'object') {
return classSituation.hasOwnProperty(courseNumber.toString());
}
}
return false;
});
} else {
// 其他阶段从courseCustomers中筛选
if (props.courseCustomers?.['课1-4']) {
filteredCustomers = props.courseCustomers['课1-4'].filter(customer => {
// 检查客户的付款行为是否发生在指定课程阶段并且类型匹配
let hasPaymentInCourse = false;
// 优先使用time_and_camp_stage字段判断付款行为发生的课程阶段
if (customer.time_and_camp_stage && Array.isArray(customer.time_and_camp_stage)) {
hasPaymentInCourse = customer.time_and_camp_stage.some(record => {
return record.camp_stage === `${courseNumber}` && record.pay_status === stageType;
});
}
// 如果time_and_camp_stage没有匹配的记录则回退到class_num字段
if (!hasPaymentInCourse) {
const hasAttendedCourse = customer.class_num && customer.class_num.includes(courseNumber);
hasPaymentInCourse = hasAttendedCourse && customer.type === stageType;
}
return hasPaymentInCourse;
});
}
}
// 发送子时间轴选择事件给父组件,使用不同的事件名称避免与主轴冲突
emit('sub-stage-select', {
filteredCustomers,
stageType: `${courseNumber}-${stageType}`,
customerCount: filteredCustomers.length,
courseNumber,
originalStageType: stageType,
keepSubTimeline: true // 标识保持子时间轴显示
});
};
</script>
<style lang="scss" scoped>
// 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;
$success: #22c55e;
$warning: #f59e0b;
$danger: #ef4444;
$indigo: #4f46e5;
// 到课详情区域样式
.course-details {
background: $white;
border-radius: 0.75rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
margin-top: 1rem;
overflow: hidden;
.course-details-header {
background: linear-gradient(135deg, #f8fafc, #e2e8f0);
padding: 0.75rem 1rem;
border-bottom: 1px solid $slate-200;
h4 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: $slate-700;
}
}
.course-details-content {
padding: 1rem;
max-height: 200px;
overflow-y: auto;
.no-data {
text-align: center;
color: $slate-500;
font-size: 0.875rem;
padding: 1rem 0;
}
.course-lessons {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 0.75rem;
.lesson-item {
background: $slate-50;
border-radius: 0.5rem;
padding: 0.75rem;
.lesson-header {
margin-bottom: 0.5rem;
.lesson-number {
font-size: 0.875rem;
font-weight: 600;
color: $slate-700;
}
}
.lesson-details {
display: flex;
flex-wrap: wrap;
gap: 1rem;
.detail-item {
display: flex;
align-items: center;
gap: 0.25rem;
.detail-label {
font-size: 0.75rem;
color: $slate-500;
font-weight: 500;
}
.detail-value {
font-size: 0.875rem;
color: $slate-700;
font-weight: 600;
}
}
}
}
}
}
@media (max-width: 768px) {
margin-top: 0.75rem;
.course-details-header {
padding: 0.625rem 0.75rem;
h4 {
font-size: 0.8125rem;
}
}
.course-details-content {
padding: 0.75rem;
max-height: 150px;
.course-lessons {
gap: 0.5rem;
.lesson-item {
padding: 0.625rem;
.lesson-details {
gap: 0.75rem;
.detail-item {
.detail-label {
font-size: 0.6875rem;
}
.detail-value {
font-size: 0.8125rem;
}
}
}
}
}
}
}
}
.sales-timeline-with-task-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
// Sales Timeline Styles
.sales-timeline {
padding: 1.5rem;
background: $white;
border-radius: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
@media (max-width: 768px) {
padding: 1rem;
}
}
.timeline-container {
position: relative;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
overflow-x: auto;
padding: 2rem 1rem 1rem 1rem;
@media (max-width: 768px) {
padding: 1.5rem 0.5rem 0.5rem 0.5rem;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
@media (max-width: 480px) {
padding: 1rem 0.25rem 0.25rem 0.25rem;
}
}
.timeline-line {
position: absolute;
top: 4rem;
left: 2rem;
right: 2rem;
height: 3px;
background: linear-gradient(to right, $primary, $success);
border-radius: 2px;
@media (max-width: 768px) {
top: 3rem;
left: 1.5rem;
right: 1.5rem;
}
}
.timeline-stage {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
flex: 1;
min-width: 0;
cursor: pointer;
transition: all 0.3s ease;
border-radius: 8px;
padding: 0.5rem 8px;
@media (max-width: 768px) {
gap: 0.75rem;
min-width: 120px;
}
@media (max-width: 480px) {
gap: 0.5rem;
min-width: 100px;
padding: 0.25rem 4px;
}
&:hover {
background-color: rgba(59, 130, 246, 0.05);
transform: translateY(-2px);
.marker-circle {
border-color: $primary;
transform: scale(1.1);
}
}
&.selected {
background-color: rgba(59, 130, 246, 0.1);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
.marker-circle {
border-color: $primary;
background: $primary;
transform: scale(1.2);
.stage-number {
color: $white;
}
}
}
&.active {
.marker-circle {
background: linear-gradient(135deg, $primary, $success);
color: $white;
transform: scale(1.1);
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3);
}
.stage-content {
.stage-title {
font-size: 1rem;
color: $slate-800;
font-weight: 500;
}
.stage-count {
color: $primary;
font-weight: 700;
}
}
}
}
.stage-marker {
position: relative;
z-index: 3;
.marker-circle {
width: 4rem;
height: 4rem;
border-radius: 50%;
background: $slate-200;
border: 3px solid $white;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
z-index: 3;
@media (max-width: 768px) {
width: 3rem;
height: 3rem;
}
.stage-number {
font-size: 1.125rem;
font-weight: 700;
color: $slate-600;
@media (max-width: 768px) {
font-size: 1rem;
}
}
}
}
.stage-content {
text-align: center;
width: 100%;
.stage-title {
font-size: 1rem;
font-weight: 500;
color: $slate-700;
margin: 0 0 0.75rem 0;
@media (max-width: 768px) {
font-size: 0.875rem;
}
}
.stage-stats {
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
.stage-count {
font-size: 1.5rem;
font-weight: 700;
color: $slate-600;
line-height: 1;
@media (max-width: 768px) {
font-size: 1.25rem;
}
}
.stage-label {
font-size: 0.75rem;
color: $slate-500;
font-weight: 500;
}
}
.stage-percentage {
font-size: 0.75rem;
color: $slate-400;
font-weight: 500;
background: $slate-100;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
display: inline-block;
}
}
// Task List Styles
.task-body {
padding: 1rem;
min-height: 15vh;
max-height: 50vh;
background: $white;
border-radius: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
overflow-y: auto;
@media (max-width: 768px) {
padding: 0 1rem;
}
@media (max-width: 480px) {
padding: 0 0.75rem;
}
}
.actionable-list {
display: flex;
flex-direction: column;
}
.items-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 250px));
gap: 0.45rem;
justify-content: center;
@media (max-width: 768px) {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
@media (max-width: 480px) {
grid-template-columns: repeat(2, 1fr);
gap: 0.375rem;
}
}
.action-item {
background-color: $white;
padding: 0.25rem;
border-radius: 0.5rem;
border: 1px solid $slate-200;
cursor: pointer;
transition: all 0.2s ease-in-out;
min-height: 50px;
display: flex;
flex-direction: column;
justify-content: space-between;
@media (max-width: 768px) {
padding: 0.75rem;
min-height: 90px;
}
@media (max-width: 480px) {
padding: 0.625rem;
min-height: 50px;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(0,0,0,0.05);
@media (max-width: 768px) {
transform: none;
}
}
@media (max-width: 768px) {
&:active {
transform: scale(0.98);
background-color: $slate-50;
}
}
&.active {
background-color: #eef2ff;
border-color: $indigo;
}
.item-content {
display: flex;
align-items: flex-start;
gap: 0.75rem;
width: 100%;
@media (max-width: 768px) {
gap: 0.5rem;
}
@media (max-width: 480px) {
gap: 0.375rem;
}
}
.avatar-section {
flex-shrink: 0;
.avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
overflow: hidden;
background-color: $slate-200;
display: flex;
align-items: center;
justify-content: center;
@media (max-width: 768px) {
width: 2.25rem;
height: 2.25rem;
}
@media (max-width: 480px) {
width: 2rem;
height: 2rem;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
}
}
.info-section {
flex: 1;
min-width: 0; // 允许flex项目收缩
display: flex;
flex-direction: column;
gap: 0.5rem;
@media (max-width: 768px) {
gap: 0.375rem;
}
@media (max-width: 480px) {
gap: 0.25rem;
}
}
.item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
}
.item-footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
@media (max-width: 768px) {
gap: 0.375rem;
}
@media (max-width: 480px) {
gap: 0.25rem;
}
}
.item-name-group {
display: flex;
align-items: center;
flex: 1;
min-width: 0; // 允许收缩
.icon {
margin-right: 0.5rem;
flex-shrink: 0;
}
.item-name {
font-weight: 600;
font-size: 0.875rem;
line-height: 1.3;
color: $slate-800;
// 处理名字过长的情况
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
@media (max-width: 768px) {
font-size: 0.75rem;
}
@media (max-width: 480px) {
font-size: 0.75rem;
}
}
}
.item-time {
font-size: 0.75rem;
color: $slate-500;
flex-shrink: 0;
white-space: nowrap;
@media (max-width: 768px) {
font-size: 0.6875rem;
}
@media (max-width: 480px) {
font-size: 0.625rem;
}
}
.health-status {
font-size: 0.75rem;
font-weight: bold;
@media (max-width: 768px) {
font-size: 0.6875rem;
}
@media (max-width: 480px) {
font-size: 0.625rem;
}
}
}
// Profession and Education
.profession-education {
display: flex;
align-items: center;
gap: 0.2rem;
flex-wrap: wrap;
@media (max-width: 480px) {
gap: 0.5rem;
}
.profession, .education {
font-size: 0.75rem;
border-radius: 0.3rem;
font-weight: 500;
@media (max-width: 768px) {
font-size: 0.75rem;
padding: 0.1875rem 0.375rem;
}
@media (max-width: 480px) {
font-size: 0.75rem;
padding: 0.125rem 0.25rem;
}
}
.profession {
background-color: #e0f2fe;
color: #0277bd;
}
.education {
background-color: #f3e5f5;
color: #7b1fa2;
}
.course-type {
background-color: #e8f5e8;
color: #2e7d32;
font-size: 0.75rem;
padding: 0.1rem 0.5rem;
border-radius: 0.3rem;
font-weight: 500;
display: inline-block;
@media (max-width: 768px) {
font-size: 0.75rem;
padding: 0.1875rem 0.375rem;
}
@media (max-width: 480px) {
font-size: 0.75rem;
padding: 0.125rem 0.25rem;
margin-left: 0.375rem;
}
}
.course-attendance {
display: inline-block;
// margin-top: 0.25rem;
padding: 0.1rem 0.25rem;
background: linear-gradient(135deg, #e3f2fd, #bbdefb);
border-radius: 0.3rem;
font-size: 0.75rem;
color: #1565c0;
font-weight: 500;
border: 1px solid #90caf9;
@media (max-width: 768px) {
font-size: 0.75rem;
padding: 0.1875rem 0.375rem;
}
@media (max-width: 480px) {
font-size: 0.75rem;
padding: 0.125rem 0.25rem;
}
}
}
// Health status styles
// Clickable styles
.clickable {
cursor: pointer;
color: #1976d2;
text-decoration: underline;
transition: color 0.2s ease;
&:hover {
color: #1565c0;
text-decoration: none;
}
}
/* 弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e5e7eb;
background-color: #f9fafb;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #374151;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #6b7280;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
}
.close-btn:hover {
background-color: #e5e7eb;
color: #374151;
}
.modal-body {
padding: 20px;
max-height: 60vh;
overflow-y: auto;
}
.no-messages {
text-align: center;
color: #6b7280;
font-size: 16px;
padding: 40px 20px;
}
.messages-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.message-item {
display: flex;
gap: 8px;
padding: 12px;
background-color: #f9fafb;
border-radius: 6px;
border-left: 3px solid #3b82f6;
}
.message-number {
font-weight: 600;
color: #3b82f6;
min-width: 20px;
}
.message-content {
flex: 1;
line-height: 1.5;
color: #374151;
}
/* 课1-4子时间轴样式 */
.course-sub-timeline {
margin: 16px 0;
padding: 12px;
background-color: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.sub-timeline-container {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 8px;
}
.course-timeline {
background: white;
border-radius: 6px;
padding: 8px 12px;
border: 1px solid #e5e7eb;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.course-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid #f1f5f9;
}
.course-title {
font-weight: 600;
color: #1e293b;
font-size: 14px;
}
.course-conversion {
font-size: 12px;
color: #64748b;
background-color: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
}
.mini-timeline {
display: flex;
align-items: flex-start;
gap: 4px;
position: relative;
padding-top: 6px;
}
.mini-timeline::before {
content: '';
position: absolute;
top: 12px;
left: 6px;
right: 6px;
height: 2px;
background-color: #e2e8f0;
z-index: 1;
}
.mini-stage {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 2;
flex: 1;
min-width: 0;
}
.mini-marker {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #e2e8f0;
border: 2px solid white;
transition: all 0.3s ease;
margin-bottom: 4px;
position: relative;
}
.mini-stage.active .mini-marker {
background-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.mini-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.mini-title {
font-size: 11px;
color: #64748b;
font-weight: 500;
line-height: 1.2;
margin-bottom: 2px;
word-break: break-all;
}
.mini-stage.active .mini-title {
color: #1e293b;
font-weight: 600;
}
.mini-count {
font-size: 12px;
font-weight: 600;
color: #64748b;
background-color: #f1f5f9;
padding: 1px 4px;
border-radius: 3px;
min-width: 16px;
text-align: center;
}
.mini-stage.active .mini-count {
background-color: #dbeafe;
color: #1d4ed8;
}
</style>