feat: 初始化Vue3项目并添加核心功能模块

新增项目基础结构,包括Vue3、Pinia、Element Plus等核心依赖
添加路由配置和用户认证状态管理
实现销售数据看板、客户画像、团队管理等核心功能模块
集成图表库和API请求工具,完成基础样式配置
This commit is contained in:
2025-08-12 14:34:44 +08:00
commit f93236ab36
71 changed files with 32821 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,902 @@
<template>
<div class="member-details">
<div class="details-header" @click="toggleDetailsCollapse">
<h2>{{ selectedMember.name }} 的详细数据</h2>
<div class="collapse-toggle" :class="{ 'collapsed': isDetailsCollapsed }">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 4l4 4H4l4-4z"/>
</svg>
</div>
</div>
<div class="details-content">
<div class="details-grid" v-show="!isDetailsCollapsed" :class="{ 'collapsing': isDetailsCollapsed }">
<div class="detail-card">
<div class="detail-label">总通话次数</div>
<div class="detail-value">{{ selectedMember.calls }} </div>
</div>
<div class="detail-card">
<div class="detail-label">通话时长</div>
<div class="detail-value">{{ selectedMember.callTime }} 小时</div>
</div>
<div class="detail-card">
<div class="detail-label">新增客户</div>
<div class="detail-value">{{ selectedMember.newClients }} </div>
</div>
<div class="detail-card">
<div class="detail-label">成交单数</div>
<div class="detail-value">{{ selectedMember.deals }} </div>
</div>
<div class="detail-card">
<div class="detail-label">总业绩</div>
<div class="detail-value">¥{{ selectedMember.performance.toLocaleString() }}</div>
</div>
<div class="detail-card">
<div class="detail-label">平均单价</div>
<div class="detail-value">¥{{ selectedMember.avgDealValue.toLocaleString() }}</div>
</div>
</div>
</div>
<!-- 指导建议 -->
<div class="guidance-section">
<div class="guidance-header" @click="toggleGuidanceCollapse">
<h3>💡 指导建议</h3>
<div class="collapse-toggle" :class="{ 'collapsed': isGuidanceCollapsed }">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 4l4 4H4l4-4z"/>
</svg>
</div>
</div>
<div class="guidance-cards" v-show="!isGuidanceCollapsed" :class="{ 'collapsing': isGuidanceCollapsed }">
<div class="guidance-card" v-if="getGuidanceForMember(selectedMember).length > 0">
<div class="guidance-item" v-for="(guidance, index) in getGuidanceForMember(selectedMember)" :key="index">
<div class="guidance-icon" :class="guidance.type">
{{ guidance.icon }}
</div>
<div class="guidance-content">
<h4 class="guidance-title">{{ guidance.title }}</h4>
<p class="guidance-description">{{ guidance.description }}</p>
<div class="guidance-action" v-if="guidance.action">
<span class="action-label">建议行动:</span>
<span class="action-text">{{ guidance.action }}</span>
</div>
</div>
</div>
</div>
<div class="no-guidance" v-else>
<div class="celebration-icon">🎉</div>
<h4>表现优秀</h4>
<p>{{ selectedMember.name }} 的各项指标都很不错继续保持这种状态</p>
</div>
</div>
</div>
<!-- 录音列表 -->
<div class="recordings-section">
<div class="recordings-header" @click="toggleRecordingsCollapse">
<h3>🎧 通话录音</h3>
<div class="collapse-toggle" :class="{ 'collapsed': isRecordingsCollapsed }">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 4l4 4H4l4-4z"/>
</svg>
</div>
</div>
<div class="recordings-content" v-show="!isRecordingsCollapsed" :class="{ 'collapsing': isRecordingsCollapsed }">
<div class="recordings-list" v-if="getRecordingsForMember(selectedMember).length > 0">
<div class="recording-item" v-for="(recording, index) in getRecordingsForMember(selectedMember)" :key="index">
<div class="recording-info">
<div class="recording-title">{{ recording.title }}</div>
<div class="recording-meta">
<span class="recording-date">{{ recording.date }}</span>
<span class="recording-duration">{{ recording.duration }}</span>
<span class="recording-type" :class="recording.type">{{ recording.typeLabel }}</span>
</div>
</div>
<button class="download-btn" @click="downloadRecording(recording)">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 12l-4-4h3V4h2v4h3l-4 4z"/>
<path d="M2 14h12v1H2z"/>
</svg>
下载
</button>
</div>
</div>
<div class="no-recordings" v-else>
<div class="no-data-icon">📞</div>
<h4>暂无录音</h4>
<p>{{ selectedMember.name }} 还没有通话录音记录</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, defineProps, watch, nextTick } from 'vue'
// 定义props
const props = defineProps({
selectedMember: {
type: Object,
required: true
}
})
// 详细数据折叠状态
const isDetailsCollapsed = ref(false)
// 指导建议折叠状态(默认展开)
const isGuidanceCollapsed = ref(false)
// 录音列表折叠状态(默认展开)
const isRecordingsCollapsed = ref(false)
// 切换详细数据折叠状态
const toggleDetailsCollapse = () => {
isDetailsCollapsed.value = !isDetailsCollapsed.value
}
// 切换指导建议折叠状态
const toggleGuidanceCollapse = () => {
isGuidanceCollapsed.value = !isGuidanceCollapsed.value
}
// 切换录音列表折叠状态
const toggleRecordingsCollapse = () => {
isRecordingsCollapsed.value = !isRecordingsCollapsed.value
}
// 获取成员录音列表
const getRecordingsForMember = (member) => {
// 模拟录音数据实际项目中应该从API获取
const recordings = [
{
id: 1,
title: '客户咨询 - 张先生',
date: '2024-01-15 14:30',
duration: '12:35',
type: 'consultation',
typeLabel: '咨询',
fileUrl: '/recordings/test_recording.m4a' // 使用测试文件
},
{
id: 2,
title: '跟进回访 - 李女士',
date: '2024-01-15 10:15',
duration: '8:42',
type: 'followup',
typeLabel: '回访',
fileUrl: '/recordings/test_recording.m4a' // 使用测试文件
},
{
id: 3,
title: '成交确认 - 王总',
date: '2024-01-14 16:20',
duration: '15:18',
type: 'deal',
typeLabel: '成交',
fileUrl: '/recordings/test_recording.m4a' // 使用测试文件
}
]
// 根据成员ID返回对应的录音这里简化处理
return recordings.slice(0, Math.min(3, member.calls || 0))
}
// 下载录音文件
const downloadRecording = (recording) => {
// 创建下载链接
const link = document.createElement('a')
link.href = recording.fileUrl
link.download = `${recording.title}_${recording.date.replace(/[:\s]/g, '_')}.m4a`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// 实际项目中可能需要调用API来获取下载链接
console.log('下载录音:', recording.title)
}
// 获取成员指导建议
const getGuidanceForMember = (member) => {
const guidance = []
// 业绩相关建议
if (member.performance === 0) {
guidance.push({
type: 'urgent',
icon: '🚨',
title: '业绩突破',
description: '当前还未有成交记录,需要重点关注转化技巧和客户跟进。',
action: '建议参加销售技巧培训,加强客户需求挖掘'
})
} else if (member.performance < 80000) {
guidance.push({
type: 'warning',
icon: '📈',
title: '业绩提升',
description: '业绩有提升空间,可以通过优化沟通策略来提高转化率。',
action: '分析高业绩同事的沟通技巧,制定个人提升计划'
})
}
// 转化率相关建议
if (member.conversion < 3.0) {
guidance.push({
type: 'urgent',
icon: '🎯',
title: '转化率优化',
description: '转化率偏低,需要提升客户沟通和需求挖掘能力。',
action: '重点学习客户心理分析和异议处理技巧'
})
} else if (member.conversion < 6.0) {
guidance.push({
type: 'info',
icon: '💬',
title: '沟通技巧',
description: '转化率还有提升空间,建议优化沟通话术和客户关系维护。',
action: '观摩优秀同事的通话录音,学习有效沟通技巧'
})
}
// 通话相关建议
if (member.calls < 100) {
guidance.push({
type: 'warning',
icon: '📞',
title: '通话量提升',
description: '通话量偏少,增加客户接触频次有助于提升业绩。',
action: '制定每日通话计划,确保充足的客户接触量'
})
}
// 客户开发建议
if (member.newClients < 5) {
guidance.push({
type: 'info',
icon: '👥',
title: '客户开发',
description: '新客户开发数量较少,可以拓展更多潜在客户渠道。',
action: '利用社交媒体和转介绍扩大客户来源'
})
}
// 平均单价建议
if (member.avgDealValue > 0 && member.avgDealValue < 25000) {
guidance.push({
type: 'success',
icon: '💰',
title: '客单价提升',
description: '可以尝试推荐更高价值的课程套餐,提升平均客单价。',
action: '学习产品组合销售技巧,挖掘客户更深层次需求'
})
}
return guidance.slice(0, 3) // 最多显示3个建议
}
// 监听selectedMember变化重置滚动条位置
watch(() => props.selectedMember, () => {
nextTick(() => {
// 获取member-details容器元素并重置滚动位置
const memberDetailsEl = document.querySelector('.member-details')
if (memberDetailsEl) {
// 使用平滑滚动动画
memberDetailsEl.scrollTo({
top: 0,
behavior: 'smooth'
})
}
})
}, { immediate: false })
</script>
<style lang="scss" scoped>
// Member Details
.member-details {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
height: 600px; // 固定高度
overflow-y: auto; // 整个卡片可滚动
// 自定义滚动条样式
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
&:hover {
background: #94a3b8;
}
}
h2 {
font-size: 1.2rem;
font-weight: 600;
color: #1e293b;
margin: 0 0 1.5rem 0;
}
.details-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
flex: 1;
align-content: start;
}
.detail-card {
background: #f8fafc;
border-radius: 8px;
padding: 0.5rem;
text-align: center;
.detail-label {
color: #64748b;
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.detail-value {
color: #1e293b;
font-size: 1.1rem;
font-weight: 600;
}
}
}
// 详细数据折叠功能样式
.details-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 0.5rem 0;
border-bottom: 1px solid #e2e8f0;
margin-bottom: 1rem;
transition: all 0.2s ease;
&:hover {
background-color: #f8fafc;
border-radius: 4px;
padding: 0.5rem;
margin: 0 -0.5rem 1rem -0.5rem;
}
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1e293b;
}
}
.collapse-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
background-color: #f1f5f9;
color: #64748b;
transition: all 0.2s ease;
&:hover {
background-color: #e2e8f0;
color: #475569;
}
svg {
transition: transform 0.2s ease;
}
&.collapsed svg {
transform: rotate(180deg);
}
}
.details-grid {
transition: all 0.3s ease;
&.collapsing {
opacity: 0;
transform: translateY(-10px);
}
}
// 录音列表样式
.recordings-section {
margin-top: 1.5rem;
}
.recordings-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 0.5rem 0;
border-bottom: 1px solid #e2e8f0;
margin-bottom: 1rem;
transition: all 0.2s ease;
&:hover {
background-color: #f8fafc;
border-radius: 4px;
padding: 0.5rem;
margin: 0 -0.5rem 1rem -0.5rem;
}
h3 {
font-size: 1.1rem;
font-weight: 600;
color: #1e293b;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
}
.recordings-content {
transition: all 0.3s ease;
&.collapsing {
opacity: 0;
transform: translateY(-10px);
}
}
.recordings-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.recording-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
}
.recording-info {
flex: 1;
}
.recording-title {
font-size: 0.9rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
.recording-meta {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.8rem;
}
.recording-date {
color: #64748b;
}
.recording-duration {
color: #475569;
font-weight: 500;
}
.recording-type {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
&.consultation {
background: #dbeafe;
color: #1e40af;
}
&.followup {
background: #fef3c7;
color: #92400e;
}
&.deal {
background: #d1fae5;
color: #065f46;
}
}
.download-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #2563eb;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
svg {
flex-shrink: 0;
}
}
.no-recordings {
text-align: center;
padding: 2rem 1rem;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
.no-data-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
h4 {
font-size: 1rem;
font-weight: 600;
color: #64748b;
margin: 0 0 0.5rem 0;
}
p {
font-size: 0.9rem;
color: #94a3b8;
margin: 0;
}
}
// 指导建议样式
.guidance-section {
margin-top: 1.5rem;
}
.guidance-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 0.5rem 0;
border-bottom: 1px solid #e2e8f0;
margin-bottom: 1rem;
transition: all 0.2s ease;
&:hover {
background-color: #f8fafc;
border-radius: 4px;
padding: 0.5rem;
margin: 0 -0.5rem 1rem -0.5rem;
}
h3 {
font-size: 1.1rem;
font-weight: 600;
color: #1e293b;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
}
.guidance-cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.guidance-card {
background: white;
border-radius: 8px;
padding: 1rem;
border: 1px solid #e2e8f0;
}
.guidance-item {
display: flex;
gap: 0.75rem;
padding: 0.75rem 0;
&:not(:last-child) {
border-bottom: 1px solid #f1f5f9;
}
}
.guidance-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
flex-shrink: 0;
&.urgent {
background: #fef2f2;
border: 1px solid #fecaca;
}
&.warning {
background: #fffbeb;
border: 1px solid #fed7aa;
}
&.info {
background: #eff6ff;
border: 1px solid #bfdbfe;
}
&.success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
}
}
.guidance-content {
flex: 1;
}
.guidance-title {
font-size: 0.9rem;
font-weight: 600;
color: #1e293b;
margin: 0 0 0.25rem 0;
}
.guidance-description {
font-size: 0.8rem;
color: #64748b;
margin: 0 0 0.5rem 0;
line-height: 1.4;
}
.guidance-action {
display: flex;
flex-direction: column;
gap: 0.25rem;
.action-label {
font-size: 0.75rem;
font-weight: 500;
color: #3b82f6;
}
.action-text {
font-size: 0.8rem;
color: #1e293b;
background: #f8fafc;
padding: 0.5rem;
border-radius: 4px;
border-left: 3px solid #3b82f6;
}
}
.no-guidance {
text-align: center;
padding: 2rem 1rem;
background: white;
border-radius: 8px;
border: 1px solid #e2e8f0;
.celebration-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
h4 {
font-size: 1rem;
font-weight: 600;
color: #059669;
margin: 0 0 0.5rem 0;
}
p {
font-size: 0.9rem;
color: #64748b;
margin: 0;
}
}
// 移动端适配
@media (max-width: 768px) {
.member-details {
padding: 1rem;
height: auto;
max-height: 500px;
h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
}
.details-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.detail-card {
padding: 0.75rem;
.detail-label {
font-size: 0.8rem;
}
.detail-value {
font-size: 1rem;
}
}
}
// 录音列表适配
.recordings-section {
margin-top: 1rem;
.recordings-header {
h3 {
font-size: 1rem;
}
}
.recordings-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.recording-item {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
padding: 0.75rem;
}
.recording-info {
.recording-title {
font-size: 0.8rem;
margin-bottom: 0.25rem;
line-height: 1.3;
}
.recording-meta {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
.recording-date {
font-size: 0.7rem;
}
.recording-duration {
font-size: 0.7rem;
}
.recording-type {
align-self: flex-start;
font-size: 0.65rem;
padding: 0.2rem 0.4rem;
}
}
}
.download-btn {
align-self: center;
padding: 0.5rem 0.8rem;
font-size: 0.75rem;
svg {
width: 12px;
height: 12px;
}
}
}
// 指导建议适配
.guidance-section {
margin-top: 1rem;
.guidance-header {
h3 {
font-size: 1rem;
}
}
.guidance-item {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 0;
.guidance-icon {
width: 28px;
height: 28px;
font-size: 0.9rem;
}
}
.guidance-title {
font-size: 0.85rem;
}
.guidance-description {
font-size: 0.75rem;
}
.guidance-action {
.action-label {
font-size: 0.7rem;
}
.action-text {
font-size: 0.75rem;
padding: 0.4rem;
}
}
}
}
@media (max-width: 480px) {
.member-details {
padding: 0.75rem;
border-radius: 8px;
}
.recording-item {
padding: 0.6rem;
.recording-meta {
gap: 0.25rem;
font-size: 0.75rem;
}
.download-btn {
padding: 0.5rem 1rem;
font-size: 0.75rem;
}
}
.guidance-item {
.guidance-icon {
width: 24px;
height: 24px;
font-size: 0.8rem;
}
}
}
</style>

View File

@@ -0,0 +1,253 @@
<template>
<div class="performance-ranking">
<h2>团队成员业绩排名</h2>
<div class="team-list">
<div class="ranking-table">
<div class="table-header">
<span>排名</span>
<span>姓名</span>
<span>总业绩</span>
<span>转化率</span>
<span>加微率</span>
<span>入群率</span>
</div>
<div
v-for="member in teamMembers"
:key="member.id"
class="table-row"
:class="{ active: selectedMember.id === member.id }"
@click="selectMember(member)"
>
<span class="rank">{{ member.rank }}</span>
<span class="name">{{ member.name }}</span>
<span class="performance">¥{{ member.performance.toLocaleString() }}</span>
<span class="conversion">{{ member.conversion }}%</span>
<span class="wechat-rate">{{ member.wechatRate || 0 }}%</span>
<span class="group-rate">{{ member.groupRate || 0 }}%</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
// 定义props
const props = defineProps({
teamMembers: {
type: Array,
required: true
},
selectedMember: {
type: Object,
required: true
}
})
// 定义emits
const emit = defineEmits(['select-member'])
// 选择成员函数
const selectMember = (member) => {
emit('select-member', member)
}
</script>
<style lang="scss" scoped>
// Performance Ranking
.performance-ranking {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
height: 600px; // 固定高度
display: flex;
flex-direction: column;
overflow: hidden;
h2 {
font-size: 1.2rem;
font-weight: 600;
color: #1e293b;
margin: 0 0 1rem 0;
flex-shrink: 0;
}
.team-list {
flex: 1;
overflow-y: auto;
padding-right: 0.5rem;
min-height: 0;
// 自定义滚动条样式
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
&:hover {
background: #94a3b8;
}
}
}
.ranking-table {
flex: 1;
display: flex;
flex-direction: column;
.table-header {
display: grid;
grid-template-columns: 60px 1fr 120px 80px 90px 90px;
gap: 0.8rem;
padding: 0.75rem 0;
border-bottom: 1px solid #e2e8f0;
font-weight: 600;
color: #64748b;
font-size: 0.85rem;
white-space: nowrap;
}
.table-row {
display: grid;
grid-template-columns: 60px 1fr 120px 80px 90px 90px;
gap: 0.8rem;
padding: 0.75rem 0;
border-bottom: 1px solid #f1f5f9;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: #f8fafc;
}
&.active {
background-color: #eff6ff;
border-left: 3px solid #3b82f6;
padding-left: calc(0.75rem - 3px);
}
.rank {
font-weight: 600;
color: #3b82f6;
}
.name {
color: #1e293b;
}
.performance {
color: #059669;
font-weight: 500;
}
.conversion {
color: #64748b;
}
.wechat-rate {
color: #7c3aed;
font-weight: 500;
}
.group-rate {
color: #dc2626;
font-weight: 500;
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.performance-ranking {
padding: 1rem;
height: auto;
max-height: 400px;
h2 {
font-size: 1.1rem;
}
.ranking-table {
.table-header {
grid-template-columns: 40px 1fr 70px 55px 55px 55px;
gap: 0.3rem;
font-size: 0.75rem;
padding: 0.5rem 0;
white-space: nowrap;
}
.table-row {
grid-template-columns: 40px 1fr 70px 55px 55px 55px;
gap: 0.3rem;
font-size: 0.8rem;
padding: 0.5rem 0;
white-space: nowrap;
.rank {
font-size: 0.8rem;
}
.name {
font-size: 0.8rem;
}
.performance {
font-size: 0.75rem;
}
.conversion {
font-size: 0.75rem;
}
.wechat-rate {
font-size: 0.75rem;
}
.group-rate {
font-size: 0.75rem;
}
}
}
}
}
@media (max-width: 480px) {
.performance-ranking {
padding: 0.75rem;
border-radius: 8px;
.ranking-table {
.table-header {
grid-template-columns: 30px 1fr 55px 45px 45px 45px;
gap: 0.2rem;
font-size: 0.7rem;
white-space: nowrap;
}
.table-row {
grid-template-columns: 30px 1fr 55px 45px 45px 45px;
gap: 0.2rem;
font-size: 0.75rem;
white-space: nowrap;
}
}
}
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<div class="sales-funnel">
<h2>团队销售漏斗</h2>
<p class="funnel-description">展示从线索到成交的各个环节转化情况帮助数据驱动在各阶段的工作重点优化</p>
<div class="funnel-chart">
<div class="funnel-stage" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<span class="stage-label">线索总数</span>
<span class="stage-value">1000</span>
</div>
<div class="funnel-stage" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<span class="stage-label">有效沟通</span>
<span class="stage-value">450</span>
</div>
<div class="funnel-stage" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<span class="stage-label">到课数据</span>
<span class="stage-value">180</span>
</div>
<div class="funnel-stage" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);">
<span class="stage-label">预付定金</span>
<span class="stage-value">50</span>
</div>
<div class="funnel-stage" style="background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);">
<span class="stage-label">成功签单</span>
<span class="stage-value">12</span>
</div>
</div>
</div>
</template>
<script setup>
// 团队销售漏斗组件
</script>
<style lang="scss" scoped>
// Sales Funnel
.sales-funnel {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
h2 {
font-size: 1.2rem;
font-weight: 600;
color: #1e293b;
margin: 0 0 0.5rem 0;
}
.funnel-description {
color: #64748b;
font-size: 0.85rem;
margin: 0 0 1.5rem 0;
line-height: 1.4;
}
.funnel-chart {
display: flex;
align-items: center;
gap: 0;
height: 80px;
overflow: hidden;
}
.funnel-stage {
flex: 1;
height: 80px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
font-weight: 500;
position: relative;
clip-path: polygon(0 0, calc(100% - 20px) 0, 100% 50%, calc(100% - 20px) 100%, 0 100%, 20px 50%);
&:first-child {
clip-path: polygon(0 0, calc(100% - 20px) 0, 100% 50%, calc(100% - 20px) 100%, 0 100%);
}
&:last-child {
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, 20px 50%);
}
.stage-label {
font-size: 0.85rem;
margin-bottom: 0.25rem;
}
.stage-value {
font-size: 1.1rem;
font-weight: bold;
}
}
}
// 移动端适配
@media (max-width: 768px) {
.sales-funnel {
padding: 1rem;
h2 {
font-size: 1.1rem;
}
.funnel-description {
font-size: 0.8rem;
}
.funnel-chart {
height: auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 0.5rem;
}
.funnel-stage {
height: 50px;
clip-path: none;
border-radius: 8px;
&:nth-child(4) {
grid-column: 1;
grid-row: 2;
}
&:nth-child(5) {
grid-column: 2;
grid-row: 2;
}
.stage-label {
font-size: 0.75rem;
}
.stage-value {
font-size: 1rem;
}
}
}
}
@media (max-width: 480px) {
.sales-funnel {
padding: 0.75rem;
border-radius: 8px;
.funnel-chart {
height: auto;
}
.funnel-stage {
height: 40px;
.stage-label {
font-size: 0.7rem;
}
.stage-value {
font-size: 0.9rem;
}
}
}
}
</style>

View File

@@ -0,0 +1,115 @@
<template>
<div class="team-alerts">
<h2>团队异常预警</h2>
<div class="alert-list">
<div class="alert-item warning">
<span class="alert-icon"></span>
<span>钱鑫有102人(预计)需今日跟进通话</span>
</div>
<div class="alert-item danger">
<span class="alert-icon">🔺</span>
<span>李娜今日预计电话工作量达30%</span>
</div>
<div class="alert-item info">
<span class="alert-icon"></span>
<span>高明明客户"王先生"下次未来电话记录</span>
</div>
</div>
</div>
</template>
<script setup>
// 团队异常预警组件
</script>
<style lang="scss" scoped>
// Team Alerts
.team-alerts {
// min-height: 350px;
// max-height: 400px;
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
h2 {
font-size: 1.2rem;
font-weight: 600;
color: #1e293b;
margin: 0 0 1rem 0;
}
.alert-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.alert-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 8px;
font-size: 0.9rem;
&.warning {
background: #fef3c7;
color: #92400e;
.alert-icon {
color: #f59e0b;
}
}
&.danger {
background: #fee2e2;
color: #991b1b;
.alert-icon {
color: #ef4444;
}
}
&.info {
background: #dbeafe;
color: #1e40af;
.alert-icon {
color: #3b82f6;
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.team-alerts {
padding: 1rem;
h2 {
font-size: 1.1rem;
}
.alert-item {
padding: 0.5rem;
font-size: 0.8rem;
flex-direction: row;
align-items: center;
gap: 0.5rem;
.alert-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
}
}
}
@media (max-width: 480px) {
.team-alerts {
padding: 0.75rem;
border-radius: 8px;
}
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<div class="team-report">
<h2>今日团队实时战报</h2>
<div class="report-grid">
<div class="report-card">
<div class="card-header">
<span class="card-title">团队总通话</span>
<span class="card-trend positive">+10% vs 昨日</span>
</div>
<div class="card-value">873 </div>
</div>
<div class="report-card">
<div class="card-header">
<span class="card-title">有效通话时长</span>
<span class="card-trend negative">-5% vs 昨日</span>
</div>
<div class="card-value">25.4 小时</div>
</div>
<div class="report-card">
<div class="card-header">
<span class="card-title">新增意向客户</span>
<span class="card-trend positive">+15% vs 昨日</span>
</div>
<div class="card-value">43 </div>
</div>
<div class="report-card">
<div class="card-header">
<span class="card-title">新增成交</span>
<span class="card-trend positive">+20% vs 昨日</span>
</div>
<div class="card-value">12 </div>
</div>
<div class="report-card">
<div class="card-header">
<span class="card-title">总业绩</span>
<span class="card-trend positive">+8% vs 昨日</span>
</div>
<div class="card-value">65,000 </div>
</div>
<div class="report-card">
<div class="card-header">
<span class="card-title">团队人均产出</span>
<span class="card-trend positive">+9% vs 昨日</span>
</div>
<div class="card-value">13,000 </div>
</div>
</div>
</div>
</template>
<script setup>
// 今日团队实时战报组件
</script>
<style lang="scss" scoped>
// Team Report
.team-report {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
h2 {
font-size: 1.2rem;
font-weight: 600;
color: #1e293b;
margin: 0 0 1.5rem 0;
}
.report-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.report-card {
padding: 1rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.card-title {
color: #64748b;
font-size: 0.85rem;
}
.card-trend {
font-size: 0.75rem;
font-weight: 500;
&.positive {
color: #059669;
}
&.negative {
color: #dc2626;
}
}
.card-value {
font-size: 1.5rem;
font-weight: bold;
color: #1e293b;
}
}
}
// 移动端适配
@media (max-width: 768px) {
.team-report {
padding: 1rem;
h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
}
.report-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.report-card {
padding: 0.75rem;
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.card-title {
font-size: 0.8rem;
}
.card-trend {
font-size: 0.7rem;
}
.card-value {
font-size: 1.25rem;
}
}
}
}
@media (max-width: 480px) {
.team-report {
padding: 0.75rem;
border-radius: 8px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,992 @@
<template>
<div class="customer-detail-container">
<div v-if="selectedContact" class="customer-detail-content">
<!-- 头部信息 -->
<div class="customer-header">
<h3>{{ selectedContact.name }}</h3>
<div class="action-buttons">
<button
@click="startBasicAnalysis"
class="analysis-button"
:disabled="isBasicAnalysisLoading"
>
{{ isBasicAnalysisLoading ? '基础分析中...' : '基础信息分析' }}
</button>
<button
@click="startSopAnalysis"
class="analysis-button sop-button"
:disabled="isSopAnalysisLoading"
>
{{ isSopAnalysisLoading ? 'SOP分析中...' : 'SOP通话分析' }}
</button>
<button
@click="startDemandAnalysis"
class="analysis-button demand-button"
:disabled="isDemandAnalysisLoading"
>
{{ isDemandAnalysisLoading ? '诉求分析中...' : '客户诉求分析' }}
</button>
</div>
</div>
<!-- 分析区域 -->
<div class="analysis-areas">
<!-- 上方两个区域 -->
<div class="top-row">
<!-- 基础信息分析 -->
<div class="analysis-section basic-analysis">
<div class="section-header">
<h4>基础信息分析</h4>
</div>
<div class="section-content">
<div class="text-content" v-if="basicAnalysisResult">
<div class="analysis-text" v-html="formattedBasicAnalysis"></div>
</div>
<div class="placeholder-text" v-else>
<p>点击"基础信息分析"按钮开始分析客户基础信息</p>
</div>
</div>
</div>
<!-- SOP通话分析 -->
<div class="analysis-section sop-analysis">
<div class="section-header">
<h4>SOP通话分析</h4>
</div>
<div class="section-content">
<div class="text-content" v-if="sopAnalysisResult">
<div class="analysis-text" v-html="formattedSopAnalysis"></div>
</div>
<div class="placeholder-text" v-else>
<p>点击"SOP通话分析"按钮开始分析通话记录</p>
</div>
</div>
</div>
</div>
<!-- 下方整行区域 -->
<div class="bottom-row">
<!-- 客户诉求分析 -->
<div class="analysis-section demand-analysis">
<div class="section-header">
<h4>客户诉求分析</h4>
</div>
<div class="section-content">
<div class="text-content" v-if="demandAnalysisResult">
<div class="analysis-text" v-html="formattedDemandAnalysis"></div>
</div>
<div class="placeholder-text" v-else>
<p>点击"客户诉求分析"按钮开始深度分析客户需求和诉求</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 未选择客户时的提示 -->
<div v-else class="no-selection">
<p>请选择一个客户查看详情</p>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue';
import { SimpleChatService } from '@/utils/ChatService.js';
import MarkdownIt from 'markdown-it';
// 定义props
const props = defineProps({
selectedContact: {
type: Object,
default: null
}
});
// 分析结果状态
const basicAnalysisResult = ref(''); // 基础信息分析结果
const sopAnalysisResult = ref(''); // SOP通话分析结果
const demandAnalysisResult = ref(''); // 客户诉求分析结果
// 加载状态
const isBasicAnalysisLoading = ref(false); // 基础分析加载状态
const isSopAnalysisLoading = ref(false); // SOP分析加载状态
const isDemandAnalysisLoading = ref(false); // 诉求分析加载状态
// Dify API配置
const DIFY_API_KEY_01 = 'app-wbR1P1j6kvdBK8Q1qXzdswzP';
const DIFY_API_KEY = 'app-37VXHRieOnq17BSury9ONavG';
// 初始化ChatService
const chatService_01 = new SimpleChatService(DIFY_API_KEY_01);
const chatService = new SimpleChatService(DIFY_API_KEY);
// 初始化markdown-it
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true
});
// 计算属性:格式化基础分析结果
const formattedBasicAnalysis = computed(() => {
if (!basicAnalysisResult.value) return '';
return md.render(basicAnalysisResult.value);
});
// 计算属性格式化SOP分析结果
const formattedSopAnalysis = computed(() => {
if (!sopAnalysisResult.value) return '';
return md.render(sopAnalysisResult.value);
});
// 计算属性:格式化诉求分析结果
const formattedDemandAnalysis = computed(() => {
if (!demandAnalysisResult.value) return '';
return md.render(demandAnalysisResult.value);
});
// 监听selectedContact变化重置所有分析结果
watch(() => props.selectedContact, (newContact) => {
if (newContact) {
// 重置所有分析状态
basicAnalysisResult.value = '';
sopAnalysisResult.value = '';
demandAnalysisResult.value = '';
isBasicAnalysisLoading.value = false;
isSopAnalysisLoading.value = false;
isDemandAnalysisLoading.value = false;
} else {
// 清空所有结果
basicAnalysisResult.value = '';
sopAnalysisResult.value = '';
demandAnalysisResult.value = '';
isBasicAnalysisLoading.value = false;
isSopAnalysisLoading.value = false;
isDemandAnalysisLoading.value = false;
}
}, { immediate: true });
// 基础信息分析
const startBasicAnalysis = async () => {
if (!props.selectedContact) return;
isBasicAnalysisLoading.value = true;
basicAnalysisResult.value = '';
const query = `请对客户进行基础信息分析:
客户姓名:${props.selectedContact.name}
联系电话:${props.selectedContact.phone || '未提供'}
邮箱:${props.selectedContact.email || '未提供'}
公司:${props.selectedContact.company || '未提供'}
职位:${props.selectedContact.position || '未提供'}
销售阶段:${props.selectedContact.salesStage || '未知'}
健康度:${props.selectedContact.health || '未知'}%
请分析客户的基本情况、背景信息和初步画像。`;
try {
await chatService_01.sendMessage(
query,
(update) => {
basicAnalysisResult.value = update.content;
},
() => {
isBasicAnalysisLoading.value = false;
console.log('基础信息分析完成');
}
);
} catch (error) {
console.error('基础信息分析失败:', error);
basicAnalysisResult.value = `分析失败: ${error.message}`;
isBasicAnalysisLoading.value = false;
}
};
// SOP通话分析
const startSopAnalysis = async () => {
if (!props.selectedContact) return;
isSopAnalysisLoading.value = true;
sopAnalysisResult.value = '';
const query = `请对客户 ${props.selectedContact.name} 进行SOP通话分析
基于标准销售流程(SOP),分析以下方面:
1. 通话质量评估
2. 销售流程执行情况
3. 客户响应度分析
4. 沟通效果评价
5. 改进建议
客户当前状态:${props.selectedContact.salesStage || '未知'}
健康度:${props.selectedContact.health || '未知'}%`;
try {
await chatService.sendMessage(
query,
(update) => {
sopAnalysisResult.value = update.content;
},
() => {
isSopAnalysisLoading.value = false;
console.log('SOP通话分析完成');
}
);
} catch (error) {
console.error('SOP通话分析失败:', error);
sopAnalysisResult.value = `分析失败: ${error.message}`;
isSopAnalysisLoading.value = false;
}
};
// 客户诉求分析
const startDemandAnalysis = async () => {
if (!props.selectedContact) return;
isDemandAnalysisLoading.value = true;
demandAnalysisResult.value = '';
const query = `请对客户 ${props.selectedContact.name} 进行深度诉求分析:
请从以下维度分析客户的真实需求和诉求:
1. 显性需求分析(客户明确表达的需求)
2. 隐性需求挖掘(潜在的、未明确表达的需求)
3. 痛点识别(客户面临的主要问题和挑战)
4. 决策因素分析(影响客户决策的关键因素)
5. 价值期望(客户期望获得的价值和收益)
6. 风险顾虑(客户可能的担忧和顾虑)
7. 个性化建议(针对性的解决方案建议)
客户信息:
姓名:${props.selectedContact.name}
公司:${props.selectedContact.company || '未提供'}
职位:${props.selectedContact.position || '未提供'}
销售阶段:${props.selectedContact.salesStage || '未知'}
健康度:${props.selectedContact.health || '未知'}%`;
try {
await chatService.sendMessage(
query,
(update) => {
demandAnalysisResult.value = update.content;
},
() => {
isDemandAnalysisLoading.value = false;
console.log('客户诉求分析完成');
}
);
} catch (error) {
console.error('客户诉求分析失败:', error);
demandAnalysisResult.value = `分析失败: ${error.message}`;
isDemandAnalysisLoading.value = false;
}
};
</script>
<style lang="scss" scoped>
// Color Palette
$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;
$blue: #3b82f6;
$green: #22c55e;
$amber: #f59e0b;
$red: #ef4444;
$indigo: #4f46e5;
$purple: #a855f7;
// 容器样式
.customer-detail-container {
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 16px;
// PC端保持一致布局
@media (min-width: 1024px) {
// padding: 24px;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
padding: 20px;
}
// 移动端适配
@media (max-width: 768px) {
padding: 12px;
}
// 小屏移动端适配
@media (max-width: 480px) {
padding: 8px;
}
}
// 客户详情内容
.customer-detail-content {
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
// 客户头部信息
.customer-header {
background: $white;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid $slate-200;
display: flex;
justify-content: space-between;
align-items: center;
// PC端保持一致布局
@media (min-width: 1024px) {
padding: 20px;
border-radius: 12px;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
padding: 18px;
border-radius: 10px;
}
// 移动端适配
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: 16px;
padding: 16px;
}
// 小屏移动端适配
@media (max-width: 480px) {
padding: 12px;
gap: 12px;
}
h3 {
margin: 0;
color: $slate-800;
font-size: 20px;
font-weight: 600;
// PC端保持一致布局
@media (min-width: 1024px) {
font-size: 24px;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
font-size: 22px;
}
// 移动端适配
@media (max-width: 768px) {
font-size: 18px;
}
// 小屏移动端适配
@media (max-width: 480px) {
font-size: 16px;
}
}
.action-buttons {
display: flex;
gap: 12px;
// 移动端适配
@media (max-width: 768px) {
width: 100%;
flex-direction: column;
gap: 8px;
}
// 小屏移动端适配
@media (max-width: 480px) {
gap: 6px;
}
.analysis-button {
padding: 10px 16px;
background: $blue;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
white-space: nowrap;
// PC端保持一致布局
@media (min-width: 1024px) {
padding: 12px 20px;
font-size: 15px;
border-radius: 8px;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
padding: 11px 18px;
font-size: 14px;
border-radius: 7px;
}
// 移动端适配
@media (max-width: 768px) {
width: 100%;
padding: 12px 16px;
font-size: 14px;
text-align: center;
}
// 小屏移动端适配
@media (max-width: 480px) {
padding: 10px 12px;
font-size: 13px;
}
&:hover:not(:disabled) {
background: #2563eb;
transform: translateY(-1px);
}
&:disabled {
background: $slate-400;
cursor: not-allowed;
transform: none;
}
&.sop-button {
background: $green;
&:hover:not(:disabled) {
background: #16a34a;
}
}
&.demand-button {
background: $purple;
&:hover:not(:disabled) {
background: #9333ea;
}
}
}
}
}
// 分析区域
.analysis-areas {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
}
// 上方行
.top-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
height: 45%;
// PC端保持一致布局
@media (min-width: 1024px) {
gap: 20px;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
gap: 18px;
}
// 移动端适配
@media (max-width: 768px) {
grid-template-columns: 1fr;
height: auto;
gap: 16px;
}
// 小屏移动端适配
@media (max-width: 480px) {
gap: 12px;
}
}
// 下方行
.bottom-row {
height: 55%;
}
// 分析区域样式
.analysis-section {
background: $white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid $slate-200;
display: flex;
flex-direction: column;
overflow: hidden;
// PC端保持一致布局
@media (min-width: 1024px) {
border-radius: 12px;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
border-radius: 10px;
}
// 移动端适配
@media (max-width: 768px) {
min-height: 300px;
}
// 小屏移动端适配
@media (max-width: 480px) {
min-height: 250px;
}
.section-header {
padding: 12px 16px;
background: $slate-50;
border-bottom: 1px solid $slate-200;
// PC端保持一致布局
@media (min-width: 1024px) {
padding: 16px 20px;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
padding: 14px 18px;
}
// 移动端适配
@media (max-width: 768px) {
padding: 12px 16px;
}
// 小屏移动端适配
@media (max-width: 480px) {
padding: 10px 12px;
}
h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: $slate-700;
// PC端保持一致布局
@media (min-width: 1024px) {
font-size: 18px;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
font-size: 17px;
}
// 移动端适配
@media (max-width: 768px) {
font-size: 15px;
}
// 小屏移动端适配
@media (max-width: 480px) {
font-size: 14px;
}
}
}
.section-content {
flex: 1;
padding: 16px;
overflow-y: auto;
// PC端保持一致布局
@media (min-width: 1024px) {
padding: 20px;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
padding: 18px;
}
// 移动端适配
@media (max-width: 768px) {
padding: 16px;
}
// 小屏移动端适配
@media (max-width: 480px) {
padding: 12px;
}
.text-content {
height: 100%;
.analysis-text {
color: $slate-700;
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
// PC端保持一致布局
@media (min-width: 1024px) {
font-size: 15px;
line-height: 1.7;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
font-size: 14px;
line-height: 1.65;
}
// 移动端适配
@media (max-width: 768px) {
font-size: 13px;
line-height: 1.6;
}
// 小屏移动端适配
@media (max-width: 480px) {
font-size: 12px;
line-height: 1.5;
}
}
}
.placeholder-text {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background: $slate-50;
border-radius: 6px;
border: 2px dashed $slate-200;
p {
margin: 0;
color: $slate-500;
font-size: 14px;
text-align: center;
padding: 16px;
// PC端保持一致布局
@media (min-width: 1024px) {
font-size: 15px;
padding: 20px;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
font-size: 14px;
padding: 18px;
}
// 移动端适配
@media (max-width: 768px) {
font-size: 13px;
padding: 16px;
}
// 小屏移动端适配
@media (max-width: 480px) {
font-size: 12px;
padding: 12px;
}
}
}
}
// 不同分析区域的主题色
&.basic-analysis {
.section-header {
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
h4 {
color: $blue;
}
}
}
&.sop-analysis {
.section-header {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
h4 {
color: $green;
}
}
}
&.demand-analysis {
.section-header {
background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%);
h4 {
color: $purple;
}
}
}
}
// Markdown样式
.analysis-text {
// Markdown样式
h1, h2, h3, h4, h5, h6 {
margin: 1rem 0 0.5rem 0;
font-weight: 600;
color: $slate-800;
&:first-child {
margin-top: 0;
}
}
h1 { font-size: 1.25rem; }
h2 { font-size: 1.125rem; }
h3 { font-size: 1rem; }
h4 { font-size: 0.875rem; }
h5 { font-size: 0.75rem; }
h6 { font-size: 0.75rem; }
p {
margin: 0.5rem 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
ul, ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
li {
margin: 0.25rem 0;
}
}
blockquote {
margin: 1rem 0;
padding: 0.5rem 1rem;
border-left: 4px solid $blue;
background: rgba(59, 130, 246, 0.05);
font-style: italic;
}
code {
background: $slate-100;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
}
pre {
background: $slate-100;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 1rem 0;
code {
background: none;
padding: 0;
}
}
strong {
font-weight: 600;
color: $slate-800;
}
em {
font-style: italic;
}
a {
color: $blue;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
hr {
margin: 1.5rem 0;
border: none;
border-top: 1px solid $slate-200;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
th, td {
padding: 0.5rem;
border: 1px solid $slate-200;
text-align: left;
}
th {
background: $slate-50;
font-weight: 600;
}
}
}
// 未选择状态
.no-selection {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background: $slate-50;
border-radius: 8px;
border: 2px dashed $slate-200;
color: $slate-500;
// PC端保持一致布局
@media (min-width: 1024px) {
border-radius: 12px;
min-height: 500px;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
border-radius: 10px;
min-height: 450px;
}
// 移动端适配
@media (max-width: 768px) {
height: 400px;
border-radius: 8px;
}
// 小屏移动端适配
@media (max-width: 480px) {
height: 300px;
border-radius: 6px;
}
p {
margin: 0;
font-size: 1rem;
text-align: center;
padding: 1rem;
// PC端保持一致布局
@media (min-width: 1024px) {
font-size: 1.125rem;
padding: 1.5rem;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
font-size: 1.0625rem;
padding: 1.25rem;
}
// 移动端适配
@media (max-width: 768px) {
font-size: 0.875rem;
padding: 1rem;
}
// 小屏移动端适配
@media (max-width: 480px) {
font-size: 0.75rem;
padding: 0.75rem;
}
}
}
h2.section-title {
font-size: 1.25rem;
font-weight: bold;
color: $slate-700;
}
h4 {
font-weight: 600;
color: $slate-700;
margin-bottom: 0.5rem;
}
// Context Panel
.section-card {
background-color: $white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
// padding: 1rem;
// margin-top: 12px;
}
// 分析区域布局优化
.analysis-areas {
// PC端保持一致布局
@media (min-width: 1024px) {
gap: 20px;
}
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
gap: 18px;
}
// 移动端适配
@media (max-width: 768px) {
gap: 16px;
}
// 小屏移动端适配
@media (max-width: 480px) {
gap: 12px;
}
}
// 下方行适配
.bottom-row {
// 移动端适配
@media (max-width: 768px) {
height: auto;
min-height: 300px;
}
// 小屏移动端适配
@media (max-width: 480px) {
min-height: 250px;
}
}
</style>

View File

@@ -0,0 +1,577 @@
<template>
<div class="floating-todo" :class="{ 'expanded': isExpanded }">
<!-- 悬浮按钮 -->
<div class="floating-btn" @click="toggleExpanded">
<i class="icon-calendar">📅</i>
<span class="todo-count" v-if="!isExpanded">{{ todayTodos.length }}</span>
</div>
<!-- 展开的内容面板 -->
<div class="todo-panel" v-show="isExpanded">
<div class="panel-header">
<h3>今日待办</h3>
<button class="close-btn" @click="toggleExpanded">×</button>
</div>
<div class="panel-content">
<!-- 今日待办列表 -->
<div class="todo-section">
<h4>待办事项 ({{ todayTodos.length }})</h4>
<div class="todo-list">
<div
v-for="todo in todayTodos"
:key="todo.id"
class="todo-item"
:class="{ 'completed': todo.completed }"
>
<input
type="checkbox"
v-model="todo.completed"
@change="updateTodo(todo)"
>
<span class="todo-text">{{ todo.text }}</span>
<span class="todo-time">{{ todo.time }}</span>
</div>
</div>
</div>
<!-- 添加新待办 -->
<div class="add-todo-section">
<h4>添加待办</h4>
<div class="add-todo-form">
<input
v-model="newTodoText"
type="text"
placeholder="输入待办事项..."
@keyup.enter="addTodo"
class="todo-input"
>
<input
v-model="newTodoTime"
type="time"
class="time-input"
>
<button @click="addTodo" class="add-btn">添加</button>
</div>
</div>
<!-- 快捷操作 -->
<div class="quick-actions">
<button @click="addQuickTodo('回访重点客户')" class="quick-btn">回访重点客户</button>
<button @click="addQuickTodo('整理客户资料')" class="quick-btn">整理客户资料</button>
<button @click="addQuickTodo('准备明日计划')" class="quick-btn">准备明日计划</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
// 状态管理
const isExpanded = ref(false);
const newTodoText = ref('');
const newTodoTime = ref('');
// 待办事项数据
const todos = reactive([
{
id: 1,
text: '回访王女士 - 付定金阶段',
time: '10:00',
completed: false,
date: new Date().toDateString()
},
{
id: 2,
text: '联系李先生 - 课程咨询',
time: '14:00',
completed: false,
date: new Date().toDateString()
},
{
id: 3,
text: '准备张总的合同材料',
time: '16:00',
completed: true,
date: new Date().toDateString()
},
{
id: 4,
text: '整理本周客户跟进报告',
time: '18:00',
completed: false,
date: new Date().toDateString()
}
]);
// 计算今日待办
const todayTodos = computed(() => {
const today = new Date().toDateString();
return todos.filter(todo => todo.date === today);
});
// 方法
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
const updateTodo = (todo) => {
// 这里可以添加保存到本地存储或发送到服务器的逻辑
console.log('Todo updated:', todo);
};
const addTodo = () => {
if (newTodoText.value.trim()) {
const newTodo = {
id: Date.now(),
text: newTodoText.value,
time: newTodoTime.value || '09:00',
completed: false,
date: new Date().toDateString()
};
todos.push(newTodo);
newTodoText.value = '';
newTodoTime.value = '';
}
};
const addQuickTodo = (text) => {
const newTodo = {
id: Date.now(),
text: text,
time: '09:00',
completed: false,
date: new Date().toDateString()
};
todos.push(newTodo);
};
// 初始化
onMounted(() => {
// 可以从本地存储加载数据
const savedTodos = localStorage.getItem('floating-todos');
if (savedTodos) {
const parsed = JSON.parse(savedTodos);
todos.splice(0, todos.length, ...parsed);
}
});
// 监听数据变化,保存到本地存储
const saveTodos = () => {
localStorage.setItem('floating-todos', JSON.stringify(todos));
};
</script>
<style lang="scss" scoped>
.floating-todo {
position: fixed;
top: 20px;
left: 20px;
z-index: 1000;
.floating-btn {
width: 60px;
height: 60px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
transition: all 0.3s ease;
position: relative;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 25px rgba(102, 126, 234, 0.5);
}
.icon-calendar {
font-size: 24px;
color: white;
}
.todo-count {
position: absolute;
top: -5px;
right: -5px;
background: #ff4757;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
}
}
.todo-panel {
position: absolute;
top: 70px;
left: 0;
width: 350px;
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
overflow: hidden;
animation: slideDown 0.3s ease;
.panel-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
}
.panel-content {
padding: 20px;
max-height: 400px;
overflow-y: auto;
.todo-section {
margin-bottom: 20px;
h4 {
margin: 0 0 10px 0;
font-size: 14px;
color: #333;
font-weight: 600;
}
.todo-list {
.todo-item {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&.completed {
opacity: 0.6;
.todo-text {
text-decoration: line-through;
}
}
input[type="checkbox"] {
margin-right: 10px;
width: 16px;
height: 16px;
}
.todo-text {
flex: 1;
font-size: 13px;
color: #333;
}
.todo-time {
font-size: 12px;
color: #666;
background: #f5f5f5;
padding: 2px 6px;
border-radius: 4px;
}
}
}
}
.add-todo-section {
margin-bottom: 20px;
h4 {
margin: 0 0 10px 0;
font-size: 14px;
color: #333;
font-weight: 600;
}
.add-todo-form {
display: flex;
gap: 8px;
flex-wrap: wrap;
.todo-input {
flex: 1;
min-width: 150px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 13px;
&:focus {
outline: none;
border-color: #667eea;
}
}
.time-input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 13px;
&:focus {
outline: none;
border-color: #667eea;
}
}
.add-btn {
padding: 8px 16px;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #5a6fd8;
}
}
}
}
.quick-actions {
.quick-btn {
display: block;
width: 100%;
margin-bottom: 8px;
padding: 8px 12px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
font-size: 13px;
color: #495057;
cursor: pointer;
transition: all 0.2s;
text-align: left;
&:hover {
background: #e9ecef;
border-color: #dee2e6;
}
&:last-child {
margin-bottom: 0;
}
}
}
}
}
&.expanded {
.floating-btn {
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
}
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 移动端优化 */
@media (max-width: 768px) {
.floating-todo {
top: 15px;
left: 15px;
.floating-btn {
width: 50px;
height: 50px;
.icon-calendar {
font-size: 20px;
}
.todo-count {
width: 18px;
height: 18px;
font-size: 11px;
top: -3px;
right: -3px;
}
}
.todo-panel {
width: 300px;
top: 60px;
.panel-header {
padding: 12px 15px;
h3 {
font-size: 14px;
}
}
.panel-content {
padding: 15px;
h4 {
font-size: 13px;
}
}
.todo-item {
padding: 8px 0;
.todo-text {
font-size: 13px;
}
.todo-time {
font-size: 11px;
}
}
.todo-input {
font-size: 13px;
padding: 8px;
}
.time-input {
font-size: 13px;
padding: 8px;
}
.add-btn, .quick-btn {
font-size: 12px;
padding: 8px 12px;
}
}
}
}
/* 小屏幕优化 */
@media (max-width: 480px) {
.floating-todo {
top: 10px;
left: 10px;
.floating-btn {
width: 45px;
height: 45px;
.icon-calendar {
font-size: 18px;
}
.todo-count {
width: 16px;
height: 16px;
font-size: 10px;
}
}
.todo-panel {
width: 280px;
top: 55px;
.panel-header {
padding: 10px 12px;
h3 {
font-size: 13px;
}
}
.panel-content {
padding: 12px;
h4 {
font-size: 12px;
}
}
.todo-item {
padding: 6px 0;
.todo-text {
font-size: 12px;
line-height: 1.3;
}
.todo-time {
font-size: 10px;
}
}
.add-todo-form {
flex-direction: column;
gap: 8px;
.todo-input, .time-input {
width: 100%;
font-size: 12px;
padding: 6px;
}
.add-btn {
width: 100%;
}
}
.quick-actions {
flex-direction: column;
gap: 6px;
.quick-btn {
width: 100%;
font-size: 11px;
padding: 6px 10px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,650 @@
<template>
<div class="personal-dashboard">
<!-- 头部标题 -->
<div class="dashboard-header">
<h2>个人工作仪表板</h2>
</div>
<!-- 核心KPI & 统计卡片 -->
<div class="stats-grid">
<!-- 核心KPI -->
<div class="stat-card kpi-card">
<h3 class="card-title">核心KPI</h3>
<div class="kpi-grid">
<div class="kpi-item">
<div class="kpi-value">{{ props.kpiData.totalCalls }}</div>
<p>今日通话</p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ props.kpiData.successRate }}%</div>
<p>成功率</p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ props.kpiData.avgDuration }}<span class="kpi-unit">min</span></div>
<p>平均时长</p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ props.kpiData.conversionRate }}</div>
<p>转化率</p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ props.kpiData.assignedData }}</div>
<p>本期分配数据</p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ props.kpiData.wechatAddRate }}</div>
<p>加微率</p>
</div>
</div>
</div>
<!-- 统计指标 -->
<StatisticData
:customerCommunicationRate="props.statisticsData.customerCommunicationRate"
:averageResponseTime="props.statisticsData.averageResponseTime"
:timeoutResponseRate="props.statisticsData.timeoutResponseRate"
:severeTimeoutRate="props.statisticsData.severeTimeoutRate"
:formCompletionRate="props.statisticsData.formCompletionRate"
/>
</div>
<!-- 图表和功能区 -->
<div class="charts-section">
<!-- 客户迫切解决的问题排行榜 -->
<div class="chart-container">
<div class="chart-header">
<h3>客户迫切解决的问题</h3>
</div>
<div class="chart-content">
<div class="problem-ranking">
<div v-for="(item, index) in sortedProblemData" :key="item.name" class="ranking-item" :class="getRankingClass(index)">
<div class="rank-number">
<span class="rank-badge" :class="getRankBadgeClass(index)">{{ index + 1 }}</span>
</div>
<div class="problem-info">
<div class="problem-name">{{ item.name }}</div>
<div class="problem-count">{{ item.value }}次咨询</div>
</div>
<div class="problem-percentage">
<span class="percentage">{{ getPercentage(item.value) }}%</span>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: getPercentage(item.value) + '%' }"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 销售漏斗 -->
<div class="chart-container">
<div class="chart-header">
<h3>销售漏斗</h3>
</div>
<div class="chart-content">
<canvas ref="personalFunnelChartCanvas"></canvas>
</div>
</div>
<!-- 黄金联络时段 -->
<div class="chart-container">
<div class="chart-header">
<h3>黄金联络时段</h3>
</div>
<div class="chart-content">
<canvas ref="contactTimeChartCanvas"></canvas>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount, computed } from 'vue';
import StatisticData from './StatisticData.vue';
import * as echarts from 'echarts';
import Chart from 'chart.js/auto';
import {getTableFillingRate,getAverageResponseTime,getWeeklyActiveCommunicationRate,getTimeoutResponseRate} from "@/api/api.js"
import { useUserStore } from "@/stores/user";
// 用户store
const userStore = useUserStore();
// 定义props
const props = defineProps({
kpiData: {
type: Object,
default: () => ({
totalCalls: 128,
successRate: 75,
avgDuration: 8.5,
conversionRate: 15,
assignedData: 256,
wechatAddRate: 68
})
},
funnelData: {
type: Object,
default: () => ({
labels: ['线索', '沟通', '意向', '预约', '成交'],
data: [200, 150, 90, 40, 12]
})
},
contactTimeData: {
type: Object,
default: () => ({
labels: ['9-10点', '10-11点', '11-12点', '14-15点', '15-16点', '16-17点'],
data: [65, 85, 80, 92, 75, 60]
})
},
statisticsData: {
type: Object,
default: () => ({
customerCommunicationRate: 0,
averageResponseTime: 0,
timeoutResponseRate: 0,
severeTimeoutRate: 0,
formCompletionRate: 0
})
},
urgentProblemData: {
type: Array,
default: () => []
}
});
// Chart.js 实例
const chartInstances = {};
// DOM 元素引用
const personalFunnelChartCanvas = ref(null);
const contactTimeChartCanvas = ref(null);
// Chart.js 数据 - 使用props传递的数据
const funnelData = computed(() => props.funnelData);
const contactTimeData = computed(() => props.contactTimeData);
// --- 计算属性 ---
const sortedProblemData = computed(() => {
if (!props.urgentProblemData || !Array.isArray(props.urgentProblemData)) {
return [];
}
return [...props.urgentProblemData].sort((a, b) => b.value - a.value);
});
const totalProblemCount = computed(() => {
if (!props.urgentProblemData || !Array.isArray(props.urgentProblemData)) {
return 0;
}
return props.urgentProblemData.reduce((sum, item) => sum + item.value, 0);
});
// --- 方法 ---
// Chart.js: 创建或更新图表
const createOrUpdateChart = (chartId, canvasRef, config) => {
if (chartInstances[chartId]) {
chartInstances[chartId].destroy();
}
if (canvasRef.value) {
const ctx = canvasRef.value.getContext('2d');
chartInstances[chartId] = new Chart(ctx, config);
}
};
// Chart.js: 渲染销售漏斗图
const renderPersonalFunnelChart = () => {
const config = {
type: 'bar',
data: {
labels: funnelData.labels,
datasets: [{
label: '数量', data: funnelData.data,
backgroundColor: ['rgba(59, 130, 246, 0.8)', 'rgba(16, 185, 129, 0.8)', 'rgba(245, 158, 11, 0.8)', 'rgba(239, 68, 68, 0.8)'],
borderWidth: 1
}]
},
options: {
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
plugins: { legend: { display: false } },
scales: {
y: { grid: { display: false }, ticks: { color: '#64748b', font: { size: 11 } } },
x: { beginAtZero: true, grid: { color: 'rgba(148, 163, 184, 0.2)' }, ticks: { color: '#64748b', font: { size: 11 } } }
}
}
};
createOrUpdateChart('personalFunnel', personalFunnelChartCanvas, config);
};
// Chart.js: 渲染黄金联络时段图
const renderContactTimeChart = () => {
const config = {
type: 'line',
data: {
labels: contactTimeData.labels,
datasets: [{
label: '成功率', data: contactTimeData.data,
borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.1)',
borderWidth: 3, tension: 0.4, fill: true, pointRadius: 4,
pointBackgroundColor: '#10b981', pointBorderColor: '#ffffff', pointBorderWidth: 2
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, max: 100, grid: { color: 'rgba(148, 163, 184, 0.2)' }, ticks: { color: '#64748b', font: { size: 11 }, callback: (value) => value + '%' } },
x: { grid: { display: false }, ticks: { color: '#64748b', font: { size: 11 } } }
}
}
};
createOrUpdateChart('contactTime', contactTimeChartCanvas, config);
};
// 排行榜相关方法
const getPercentage = (value) => {
if (totalProblemCount.value === 0 || !value) {
return '0.0';
}
return ((value / totalProblemCount.value) * 100).toFixed(1);
};
const getRankingClass = (index) => ({ 'rank-first': index === 0, 'rank-second': index === 1, 'rank-third': index === 2, 'rank-other': index > 2 });
const getRankBadgeClass = (index) => ({ 'badge-gold': index === 0, 'badge-silver': index === 1, 'badge-bronze': index === 2, 'badge-default': index > 2 });
// --- 生命周期钩子 ---
onMounted(() => {
renderPersonalFunnelChart();
renderContactTimeChart();
});
onBeforeUnmount(() => {
Object.values(chartInstances).forEach(chart => chart.destroy());
});
</script>
<style lang="scss" scoped>
// --- 颜色和变量定义 ---
$slate-50: #f8fafc;
$slate-100: #f1f5f9;
$slate-200: #e2e8f0;
$slate-700: #334155;
$slate-800: #1e293b;
$slate-900: #303133;
$gray-400: #909399;
$gray-600: #606266;
$blue: #409eff;
$green: #67c23a;
$orange: #e6a23c;
$red: #f56c6c;
$white: #ffffff;
.personal-dashboard {
padding: 10px;
background-color: #f5f7fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.dashboard-header {
background: $white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 24px;
h2 {
margin: 0;
color: $slate-900;
font-size: 24px;
font-weight: 600;
}
}
// --- 统计卡片网格 ---
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.stat-card {
background: $white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
font-size: 24px;
color: $white;
&.customer-rate { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
&.response-time { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
&.timeout-rate { background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); }
&.form-rate { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); }
&.severe-timeout-rate { background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%); }
}
.stat-content {
.stat-value { font-size: 20px; font-weight: 700; color: $slate-900; margin-bottom: 4px; }
.stat-label { font-size: 14px; color: $gray-400; font-weight: 500; }
}
// --- KPI 卡片特定样式 ---
.kpi-card {
display: flex;
flex-direction: column;
align-items: stretch;
.card-title {
font-size: 18px;
font-weight: 600;
color: $slate-900;
margin: -10px 0 16px;
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
}
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
// grid-template-rows: repeat(2, 1fr);
gap: 1rem;
width: 100%;
}
.kpi-item {
text-align: center;
padding: 0.75rem;
background-color: $slate-50;
border-radius: 0.5rem;
border: 1px solid $slate-200;
.kpi-value {
font-size: 1.5rem;
font-weight: bold;
color: $slate-800;
}
.kpi-unit {
font-size: 0.875rem;
font-weight: normal;
color: $gray-400;
margin-left: 2px;
}
p {
font-size: 0.875rem;
color: $gray-600;
margin: 0.25rem 0 0;
}
}
// 统计指标卡片特定样式
.stats-grid-inner {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.75rem;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 120px;
padding: 1rem 0.5rem;
.stat-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: $white;
margin-bottom: 0.75rem;
&.customer-rate { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
&.response-time { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
&.timeout-rate { background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); }
&.form-rate { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); }
&.severe-timeout-rate { background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%); }
}
.kpi-value {
margin-bottom: 0.25rem;
}
}
// --- 图表区域 ---
.charts-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
}
.chart-container {
background: $white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
min-height: 380px;
display: flex;
flex-direction: column;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 16px;
border-bottom: 1px solid #ebeef5;
h3 { margin: 0; color: $slate-900; font-size: 18px; font-weight: 600; }
}
.chart-content {
padding-left: 20px;
padding-right: 20px;
padding-bottom: 20px;
flex-grow: 1;
position: relative;
canvas {
max-height: 280px;
}
}
.chart-select {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid $slate-200;
background-color: $slate-50;
font-size: 14px;
}
// --- 排行榜样式 ---
.problem-ranking {
max-height: 320px;
overflow-y: auto;
}
.ranking-item {
display: flex;
align-items: center;
padding: 12px 0;
&:not(:last-child) { border-bottom: 1px solid #f0f2f5; }
}
.rank-number .rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-weight: bold;
font-size: 14px;
color: $white;
&.badge-gold { background: linear-gradient(135deg, #ffd700, #ffb300); }
&.badge-silver { background: linear-gradient(135deg, #c0c0c0, #a8a8a8); }
&.badge-bronze { background: linear-gradient(135deg, #cd7f32, #b8860b); }
&.badge-default { background: linear-gradient(135deg, #6c757d, #495057); }
}
.problem-info { flex: 1; margin: 0 16px; }
.problem-name { font-size: 15px; font-weight: 500; color: #212529; margin-bottom: 4px; }
.problem-count { font-size: 13px; color: #6c757d; }
.problem-percentage { min-width: 80px; text-align: right; }
.percentage { font-size: 15px; font-weight: bold; color: #495057; margin-bottom: 6px; display: block; }
.progress-bar { width: 100%; height: 6px; background: rgba(0,0,0,0.1); border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #007bff, #0056b3); border-radius: 3px; }
.rank-first .progress-fill { background: linear-gradient(90deg, #ffd700, #ffb300); }
.rank-second .progress-fill { background: linear-gradient(90deg, #c0c0c0, #a8a8a8); }
.rank-third .progress-fill { background: linear-gradient(90deg, #cd7f32, #b8860b); }
// --- 响应式设计 ---
@media (max-width: 1200px) {
.charts-section { grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); }
}
@media (max-width: 768px) {
.personal-dashboard { padding: 15px; }
.stats-grid, .charts-section { grid-template-columns: 1fr; }
.stat-card { flex-direction: row; }
.dashboard-header {
padding: 16px;
h2 {
font-size: 20px;
}
}
.kpi-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.kpi-item {
padding: 0.5rem;
.kpi-value {
font-size: 1.25rem;
}
p {
font-size: 0.75rem;
}
}
.stats-grid-inner {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.stat-item {
min-height: 100px;
padding: 0.75rem 0.25rem;
.stat-icon {
width: 32px;
height: 32px;
font-size: 16px;
margin-bottom: 0.5rem;
}
.kpi-value {
font-size: 1.125rem;
}
p {
font-size: 0.75rem;
}
}
.chart-container {
min-height: 300px;
}
.chart-header {
padding: 16px 16px 12px;
h3 {
font-size: 16px;
}
}
.chart-content {
padding-left: 16px;
padding-right: 16px;
padding-bottom: 16px;
}
}
/* 小屏幕优化 */
@media (max-width: 480px) {
.personal-dashboard {
padding: 10px;
}
.dashboard-header {
padding: 12px;
h2 {
font-size: 18px;
}
}
.kpi-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.kpi-item {
padding: 0.75rem;
.kpi-value {
font-size: 1.5rem;
}
}
.stats-grid-inner {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.stat-item {
min-height: 80px;
padding: 1rem;
flex-direction: row;
text-align: left;
.stat-icon {
margin-bottom: 0;
margin-right: 0.75rem;
}
}
.chart-container {
min-height: 250px;
}
.charts-section {
grid-template-columns: 1fr;
gap: 16px;
}
}
</style>

View File

@@ -0,0 +1,599 @@
<template>
<div class="raw-data-cards">
<div class="cards-container">
<!-- 表单信息卡片 -->
<div class="data-card form-card">
<div class="card-header">
<div class="card-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="10,9 9,9 8,9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h3 class="card-title">表单信息</h3>
</div>
<div class="card-content">
<div class="form-data-list">
<div v-for="(field, index) in formFields" :key="index" class="form-field">
<span class="field-label">{{ field.label }}:</span>
<span class="field-value">{{ field.value }}</span>
</div>
</div>
</div>
</div>
<!-- 聊天记录和通话录音卡片 -->
<div class="data-card communication-card">
<div class="card-header">
<div class="card-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h3 class="card-title">沟通记录</h3>
</div>
<div class="card-content">
<!-- Tab 切换 -->
<div class="tab-container">
<div class="tab-buttons">
<button
class="tab-btn"
:class="{ active: activeTab === 'chat' }"
@click="activeTab = 'chat'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
聊天记录
</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'call' }"
@click="activeTab = 'call'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
通话录音
</button>
</div>
<!-- Tab 内容 -->
<div class="tab-content">
<!-- 聊天记录内容 -->
<div v-if="activeTab === 'chat'" class="chat-content">
<div class="content-header">
<span class="content-count"> {{ chatData.count }} 条消息</span>
<span class="content-time">最新: {{ chatData.lastMessage }}</span>
</div>
<div class="message-list">
<div v-for="(message, index) in chatMessages" :key="index" class="message-item">
<div class="message-header">
<span class="message-sender">{{ message.sender }}</span>
<span class="message-time">{{ message.time }}</span>
</div>
<div class="message-text">{{ message.content }}</div>
</div>
</div>
</div>
<!-- 通话录音内容 -->
<div v-if="activeTab === 'call'" class="call-content">
<div class="content-header">
<span class="content-count"> {{ callData.count }} 次通话</span>
<span class="content-time">总时长: {{ callData.totalDuration }}</span>
</div>
<div class="call-list">
<div v-for="(call, index) in callRecords" :key="index" class="call-item">
<div class="call-header">
<span class="call-type">{{ call.type }}</span>
<span class="call-duration">{{ call.duration }}</span>
<span class="call-time">{{ call.time }}</span>
</div>
<div class="call-summary">{{ call.summary }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
// Props
const props = defineProps({
selectedContact: {
type: Object,
default: () => ({})
}
})
// 当前激活的tab
const activeTab = ref('chat')
// 表单字段数据
const formFields = computed(() => {
const contact = props.selectedContact
if (!contact || !contact.details) {
return [
{ label: '姓名', value: '暂无数据' },
{ label: '联系方式', value: '暂无数据' },
{ label: '意向课程', value: '暂无数据' },
{ label: '预算范围', value: '暂无数据' }
]
}
return [
{ label: '客户姓名', value: contact.name || '暂无' },
{ label: '孩子姓名', value: contact.details.childName || '暂无' },
{ label: '孩子年龄', value: contact.details.childAge ? `${contact.details.childAge}` : '暂无' },
{ label: '关注问题', value: contact.details.concerns?.join('、') || '暂无' },
{ label: '预算范围', value: contact.details.budget || '暂无' },
{ label: '偏好时间', value: contact.details.preferredTime || '暂无' },
{ label: '销售阶段', value: contact.salesStage || '暂无' },
{ label: '健康度', value: contact.health ? `${contact.health}%` : '暂无' }
]
})
// 聊天数据
const chatData = computed(() => ({
count: props.selectedContact?.chatCount || 127,
lastMessage: props.selectedContact?.lastMessage || '1小时前'
}))
// 通话数据
const callData = computed(() => ({
count: props.selectedContact?.callCount || 5,
totalDuration: props.selectedContact?.totalCallDuration || '45分钟'
}))
// 聊天消息列表
const chatMessages = computed(() => {
return [
{
sender: '客户',
time: '今天 14:30',
content: '你好,我想了解一下数学课程的具体安排和费用情况。'
},
{
sender: '我',
time: '今天 14:32',
content: '您好我们的数学课程分为基础班和提高班根据孩子的年龄和基础来安排。费用方面基础班是6000元/期提高班是8000元/期。'
},
{
sender: '客户',
time: '今天 14:35',
content: '孩子现在8岁数学基础一般应该选择哪个班级比较合适'
},
{
sender: '我',
time: '今天 14:37',
content: '建议先从基础班开始,我们会有专业的测评来确定孩子的具体水平,然后制定个性化的学习方案。'
},
{
sender: '客户',
time: '今天 15:20',
content: '好的,那什么时候可以安排试听课呢?'
}
]
})
// 通话记录列表
const callRecords = computed(() => {
return [
{
type: '呼出',
duration: '12分钟',
time: '今天 10:30',
summary: '初次沟通了解客户基本需求。客户对数学课程比较感兴趣孩子8岁希望提高数学成绩。约定发送详细资料。'
},
{
type: '呼入',
duration: '8分钟',
time: '昨天 16:45',
summary: '客户主动来电咨询价格和上课时间。解答了关于师资力量和教学方法的问题。客户表示需要和家人商量。'
},
{
type: '呼出',
duration: '15分钟',
time: '3天前 14:20',
summary: '跟进客户需求,详细介绍了课程体系和教学理念。客户对一对一辅导很感兴趣,但对价格有些犹豫。'
},
{
type: '呼出',
duration: '6分钟',
time: '5天前 11:15',
summary: '首次电话联系,简单介绍了公司和课程概况。客户表示有兴趣,约定后续详细沟通。'
}
]
})
</script>
<style lang="scss" scoped>
.raw-data-cards {
margin: 24px 0;
}
.cards-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 16px;
}
}
.data-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 20px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-color: #d1d5db;
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
}
&.form-card::before {
background: linear-gradient(90deg, #10b981, #059669);
}
&.communication-card::before {
background: linear-gradient(90deg, #3b82f6, #1d4ed8);
}
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.card-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
color: #6b7280;
.form-card & {
background: #ecfdf5;
color: #059669;
}
.communication-card & {
background: #eff6ff;
color: #1d4ed8;
}
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #111827;
margin: 0;
flex: 1;
}
// 表单字段样式
.form-data-list {
.form-field {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
&:last-child {
border-bottom: none;
}
.field-label {
font-size: 14px;
color: #6b7280;
font-weight: 500;
}
.field-value {
font-size: 14px;
color: #1f2937;
font-weight: 500;
}
}
}
// Tab 容器样式
.tab-container {
.tab-buttons {
display: flex;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 16px;
.tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 16px;
background: none;
border: none;
font-size: 14px;
font-weight: 500;
color: #6b7280;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s ease;
&.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
&:hover {
color: #3b82f6;
}
svg {
width: 16px;
height: 16px;
}
}
}
.tab-content {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f3f4f6;
.content-count {
font-size: 12px;
font-weight: 600;
color: #3b82f6;
}
.content-time {
font-size: 12px;
color: #9ca3af;
}
}
}
}
// 聊天消息样式
.message-list {
.message-item {
margin-bottom: 16px;
padding: 12px;
border-radius: 8px;
background: #f9fafb;
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.message-sender {
font-size: 12px;
font-weight: 600;
color: #3b82f6;
}
.message-time {
font-size: 12px;
color: #9ca3af;
}
}
.message-text {
font-size: 14px;
color: #374151;
line-height: 1.5;
}
}
}
// 通话记录样式
.call-list {
.call-item {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: #f9fafb;
border-left: 4px solid #3b82f6;
.call-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.call-type {
font-size: 12px;
font-weight: 600;
padding: 4px 8px;
border-radius: 4px;
background: #dbeafe;
color: #3b82f6;
}
.call-duration {
font-size: 12px;
font-weight: 500;
color: #6b7280;
}
.call-time {
font-size: 12px;
color: #9ca3af;
}
}
.call-summary {
font-size: 14px;
color: #374151;
line-height: 1.5;
}
}
}
.card-content {
margin-bottom: 20px;
}
.card-description {
color: #6b7280;
font-size: 14px;
margin: 0 0 16px 0;
line-height: 1.5;
}
.card-stats {
display: flex;
gap: 20px;
@media (max-width: 480px) {
flex-direction: column;
gap: 12px;
}
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 12px;
color: #9ca3af;
font-weight: 500;
}
.stat-value {
font-size: 14px;
color: #111827;
font-weight: 600;
}
.card-action {
border-top: 1px solid #f3f4f6;
padding-top: 16px;
margin-top: 16px;
}
.view-btn {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: #6b7280;
font-size: 14px;
font-weight: 500;
cursor: pointer;
padding: 8px 0;
transition: color 0.2s ease;
&:hover {
color: #111827;
}
svg {
transition: transform 0.2s ease;
}
&:hover svg {
transform: translateX(2px);
}
}
@media (max-width: 768px) {
.raw-data-cards {
margin: 20px 0;
}
.data-card {
padding: 16px;
}
.card-header {
gap: 10px;
margin-bottom: 14px;
}
.card-icon {
width: 36px;
height: 36px;
}
.card-title {
font-size: 15px;
}
}
@media (max-width: 480px) {
.cards-container {
gap: 12px;
}
.data-card {
padding: 14px;
}
.card-header {
gap: 8px;
margin-bottom: 12px;
}
.card-icon {
width: 32px;
height: 32px;
}
.card-title {
font-size: 14px;
}
.card-description {
font-size: 13px;
}
}
</style>

View File

@@ -0,0 +1,318 @@
<template>
<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.count }}</span>
<span class="stage-label">位客户</span>
</div>
<div class="stage-percentage">{{ getPercentage(stage.count) }}%</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
// 定义props
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
selectedStage: {
type: String,
default: 'all'
}
});
// 定义emits
const emit = defineEmits(['stage-select']);
// 计算总客户数
const totalCustomers = computed(() => {
const baseStages = [
props.data.newData || 120,
props.data.addedWechat || 85,
props.data.filledForm || 65,
props.data.phoneCall || 45,
props.data.lessons || 32,
props.data.deposit || 25,
props.data.followUp || 18,
props.data.closed || 12
];
return Math.max(...baseStages);
});
// 销售阶段数据
const stages = computed(() => [
{ id: 0, name: 'all', displayName: '全部', count: totalCustomers.value, color: '#f3f4f6' },
{ id: 1, name: '新数据', displayName: '新数据', count: props.data.newData || 120, color: '#e3f2fd' },
{ id: 2, name: '已加微', displayName: '已加微', count: props.data.addedWechat || 85, color: '#bbdefb' },
{ id: 3, name: '已填表单', displayName: '已填表单', count: props.data.filledForm || 65, color: '#90caf9' },
{ id: 4, name: '20分钟通话', displayName: '20分钟通话', count: props.data.phoneCall || 45, color: '#64b5f6' },
{ id: 5, name: '课1-4', displayName: '课1-4', count: props.data.lessons || 32, color: '#42a5f5' },
{ id: 6, name: '付定金', displayName: '付定金', count: props.data.deposit || 25, color: '#2196f3' },
{ id: 7, name: '催单', displayName: '催单', count: props.data.followUp || 18, color: '#1e88e5' },
{ id: 8, name: '成交', displayName: '成交', count: props.data.closed || 12, color: '#1976d2' }
]);
// 计算百分比
const getPercentage = (count) => {
if (totalCustomers.value === 0) return 0;
return Math.round((count / totalCustomers.value) * 100);
};
// 选择阶段
const selectStage = (stageName) => {
emit('stage-select', stageName);
};
</script>
<style lang="scss" scoped>
// Color Palette
$primary: #3b82f6;
$success: #22c55e;
$warning: #f59e0b;
$danger: #ef4444;
$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;
.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; /* 改善iOS滚动体验 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
&::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
}
@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;
// z-index: 1;
@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 {
color: $slate-800;
font-weight: 600;
}
.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: 1.25rem;
font-weight: 500;
color: $slate-700;
margin: 0 0 0.75rem 0;
@media (max-width: 768px) {
font-size: 1.125rem;
}
}
.stage-stats {
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
.stage-count {
font-size: 2rem;
font-weight: 700;
color: $slate-600;
line-height: 1;
@media (max-width: 768px) {
font-size: 1.75rem;
}
}
.stage-label {
font-size: 0.875rem;
color: $slate-500;
font-weight: 500;
}
}
.stage-percentage {
font-size: 0.875rem;
color: $slate-400;
font-weight: 500;
background: $slate-100;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
display: inline-block;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
<template>
<div class="stat-card kpi-card">
<h3 class="card-title">统计指标</h3>
<div class="kpi-grid stats-grid-inner">
<div class="kpi-item stat-item">
<div class="stat-icon customer-rate">
<i class="el-icon-chat-dot-round"></i>
</div>
<div class="kpi-value">{{ customerCommunicationRate }}</div>
<p>活跃客户沟通率</p>
</div>
<div class="kpi-item stat-item">
<div class="stat-icon response-time">
<i class="el-icon-timer"></i>
</div>
<div class="kpi-value">{{ averageResponseTime }}<span class="kpi-unit">分钟</span></div>
<p>平均应答时间</p>
</div>
<div class="kpi-item stat-item">
<div class="stat-icon timeout-rate">
<i class="el-icon-warning"></i>
</div>
<div class="kpi-value">{{ timeoutResponseRate }}</div>
<p>超时应答率</p>
</div>
<div class="kpi-item stat-item">
<div class="stat-icon severe-timeout-rate">
<i class="el-icon-warning-outline"></i>
</div>
<div class="kpi-value">{{ severeTimeoutRate }}</div>
<p>严重超时应答率</p>
</div>
<div class="kpi-item stat-item">
<div class="stat-icon form-rate">
<i class="el-icon-document"></i>
</div>
<div class="kpi-value">{{ formCompletionRate }}</div>
<p>表格填写率</p>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
defineProps({
customerCommunicationRate: {
type: Number,
default: 0
},
averageResponseTime: {
type: Number,
default: 0
},
timeoutResponseRate: {
type: Number,
default: 0
},
severeTimeoutRate: {
type: Number,
default: 0
},
formCompletionRate: {
type: Number,
default: 0
}
});
</script>
<style lang="scss" scoped>
// --- 颜色和变量定义 ---
$slate-50: #f8fafc;
$slate-100: #f1f5f9;
$slate-200: #e2e8f0;
$slate-700: #334155;
$slate-800: #1e293b;
$slate-900: #303133;
$gray-400: #909399;
$gray-600: #606266;
$white: #ffffff;
.stat-card {
background: $white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
align-items: stretch;
}
.card-title {
font-size: 18px;
font-weight: 600;
color: $slate-900;
margin: -10px 0 16px;
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
}
// 统计指标卡片特定样式
.stats-grid-inner {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.75rem;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 120px;
padding: 1rem 0.5rem;
text-align: center;
background-color: $slate-50;
border-radius: 0.5rem;
border: 1px solid $slate-200;
.stat-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: $white;
margin-bottom: 0.75rem;
&.customer-rate { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
&.response-time { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
&.timeout-rate { background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); }
&.form-rate { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); }
&.severe-timeout-rate { background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%); }
}
.kpi-value {
font-size: 1.5rem;
font-weight: bold;
color: $slate-800;
margin-bottom: 0.25rem;
}
.kpi-unit {
font-size: 0.875rem;
font-weight: normal;
color: $gray-400;
margin-left: 2px;
}
p {
font-size: 0.875rem;
color: $gray-600;
margin: 0.25rem 0 0;
}
}
// --- 响应式设计 ---
@media (max-width: 768px) {
.stats-grid-inner {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.stat-item {
min-height: 100px;
padding: 0.75rem 0.25rem;
.stat-icon {
width: 32px;
height: 32px;
font-size: 16px;
margin-bottom: 0.5rem;
}
.kpi-value {
font-size: 1.125rem;
}
p {
font-size: 0.75rem;
}
}
}
/* 小屏幕优化 */
@media (max-width: 480px) {
.stats-grid-inner {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.stat-item {
min-height: 80px;
padding: 1rem;
flex-direction: row;
text-align: left;
.stat-icon {
margin-bottom: 0;
margin-right: 0.75rem;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,912 @@
<template>
<div class="action-items">
<div class="actions-header">
<h2>待处理事项</h2>
<div class="header-controls">
<select v-model="filterPriority" class="priority-filter">
<option value="all">全部优先级</option>
<option value="urgent">紧急</option>
<option value="high"></option>
<option value="medium"></option>
<option value="low"></option>
</select>
<button class="add-btn" @click="showAddForm = true">+ 新增</button>
</div>
</div>
<!-- 统计概览 -->
<div class="actions-summary">
<div class="summary-item urgent">
<div class="summary-count">{{ getCountByPriority('urgent') }}</div>
<div class="summary-label">紧急事项</div>
</div>
<div class="summary-item high">
<div class="summary-count">{{ getCountByPriority('high') }}</div>
<div class="summary-label">高优先级</div>
</div>
<div class="summary-item medium">
<div class="summary-count">{{ getCountByPriority('medium') }}</div>
<div class="summary-label">中优先级</div>
</div>
<div class="summary-item completed">
<div class="summary-count">{{ completedCount }}</div>
<div class="summary-label">已完成</div>
</div>
</div>
<!-- 事项列表 -->
<div class="actions-list">
<div
v-for="action in filteredActions"
:key="action.id"
class="action-item"
:class="[action.priority, { completed: action.completed, overdue: isOverdue(action.dueDate) }]"
>
<div class="action-checkbox">
<input
type="checkbox"
:checked="action.completed"
@change="toggleComplete(action.id)"
class="checkbox"
>
</div>
<div class="action-content">
<div class="action-header">
<h4 class="action-title" :class="{ completed: action.completed }">{{ action.title }}</h4>
<div class="action-meta">
<span class="priority-badge" :class="action.priority">{{ getPriorityText(action.priority) }}</span>
<span class="due-date" :class="{ overdue: isOverdue(action.dueDate) }">
{{ formatDueDate(action.dueDate) }}
</span>
</div>
</div>
<p class="action-description">{{ action.description }}</p>
<div class="action-details">
<div class="detail-item">
<span class="detail-label">关联组别:</span>
<span class="detail-value">{{ action.relatedGroup || '全部' }}</span>
</div>
<div class="detail-item" v-if="action.assignee">
<span class="detail-label">负责人:</span>
<span class="detail-value">{{ action.assignee }}</span>
</div>
<div class="detail-item" v-if="action.progress !== undefined">
<span class="detail-label">进度:</span>
<div class="progress-mini">
<div class="progress-bar-mini">
<div class="progress-fill-mini" :style="{ width: action.progress + '%' }"></div>
</div>
<span class="progress-text-mini">{{ action.progress }}%</span>
</div>
</div>
</div>
<div class="action-footer">
<div class="action-tags">
<span v-for="tag in action.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<div class="action-buttons">
<button class="btn-edit" @click="editAction(action)">编辑</button>
<button class="btn-delete" @click="deleteAction(action.id)">删除</button>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredActions.length === 0" class="empty-state">
<div class="empty-icon"></div>
<div class="empty-text">
<h3>暂无待处理事项</h3>
<p>{{ filterPriority === 'all' ? '所有事项都已处理完成' : '该优先级下暂无事项' }}</p>
</div>
</div>
<!-- 新增表单模态框 -->
<div v-if="showAddForm" class="modal-overlay" @click="showAddForm = false">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>新增待处理事项</h3>
<button class="close-btn" @click="showAddForm = false">×</button>
</div>
<form @submit.prevent="addAction" class="add-form">
<div class="form-group">
<label>标题</label>
<input v-model="newAction.title" type="text" required class="form-input">
</div>
<div class="form-group">
<label>描述</label>
<textarea v-model="newAction.description" class="form-textarea"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>优先级</label>
<select v-model="newAction.priority" class="form-select">
<option value="low"></option>
<option value="medium"></option>
<option value="high"></option>
<option value="urgent">紧急</option>
</select>
</div>
<div class="form-group">
<label>截止日期</label>
<input v-model="newAction.dueDate" type="date" class="form-input">
</div>
</div>
<div class="form-group">
<label>关联组别</label>
<select v-model="newAction.relatedGroup" class="form-select">
<option value="">全部</option>
<option value="精英组">精英组</option>
<option value="冲锋组">冲锋组</option>
<option value="突破组">突破组</option>
<option value="新星组">新星组</option>
<option value="潜力组">潜力组</option>
</select>
</div>
<div class="form-actions">
<button type="button" @click="showAddForm = false" class="btn-cancel">取消</button>
<button type="submit" class="btn-submit">添加</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
selectedGroup: {
type: Object,
default: null
}
})
// 筛选优先级
const filterPriority = ref('all')
// 显示新增表单
const showAddForm = ref(false)
// 新增事项表单数据
const newAction = ref({
title: '',
description: '',
priority: 'medium',
dueDate: '',
relatedGroup: ''
})
// 待处理事项数据
const actions = ref([
{
id: 1,
title: '突破组转化率改进计划',
description: '针对突破组转化率连续下降问题,制定具体改进措施并跟踪执行',
priority: 'urgent',
dueDate: '2024-01-15',
relatedGroup: '突破组',
assignee: '王主管',
progress: 30,
tags: ['业绩改进', '紧急'],
completed: false,
createdAt: '2024-01-10'
},
{
id: 2,
title: '新星组人员补充',
description: '新星组当前人员不足需要招聘2名新销售并安排培训',
priority: 'high',
dueDate: '2024-01-20',
relatedGroup: '新星组',
assignee: '赵主管',
progress: 60,
tags: ['人员管理', '招聘'],
completed: false,
createdAt: '2024-01-08'
},
{
id: 3,
title: '月度业绩分析报告',
description: '整理各组月度业绩数据,分析趋势并提出下月目标建议',
priority: 'medium',
dueDate: '2024-01-25',
relatedGroup: '',
assignee: '中心组长',
progress: 80,
tags: ['数据分析', '报告'],
completed: false,
createdAt: '2024-01-05'
},
{
id: 4,
title: '销售技能培训安排',
description: '组织各组销售人员参加客户沟通技巧培训',
priority: 'medium',
dueDate: '2024-01-30',
relatedGroup: '',
assignee: '培训部',
progress: 20,
tags: ['培训', '技能提升'],
completed: false,
createdAt: '2024-01-12'
},
{
id: 5,
title: '客户满意度调研',
description: '对已成交客户进行满意度调研,收集改进建议',
priority: 'low',
dueDate: '2024-02-05',
relatedGroup: '',
assignee: '客服部',
progress: 0,
tags: ['客户服务', '调研'],
completed: false,
createdAt: '2024-01-14'
},
{
id: 6,
title: '精英组激励方案制定',
description: '为表现优秀的精英组制定专项激励方案',
priority: 'medium',
dueDate: '2024-01-18',
relatedGroup: '精英组',
assignee: '人事部',
progress: 100,
tags: ['激励', '团队管理'],
completed: true,
createdAt: '2024-01-01'
}
])
// 筛选后的事项
const filteredActions = computed(() => {
let filtered = actions.value
if (filterPriority.value !== 'all') {
filtered = filtered.filter(action => action.priority === filterPriority.value)
}
// 如果选中了特定组别,优先显示相关事项
if (props.selectedGroup) {
filtered = filtered.sort((a, b) => {
const aRelated = a.relatedGroup === props.selectedGroup.name
const bRelated = b.relatedGroup === props.selectedGroup.name
if (aRelated && !bRelated) return -1
if (!aRelated && bRelated) return 1
return 0
})
}
return filtered.filter(action => !action.completed)
})
// 已完成数量
const completedCount = computed(() => {
return actions.value.filter(action => action.completed).length
})
// 按优先级获取数量
const getCountByPriority = (priority) => {
return actions.value.filter(action => action.priority === priority && !action.completed).length
}
// 切换完成状态
const toggleComplete = (id) => {
const action = actions.value.find(a => a.id === id)
if (action) {
action.completed = !action.completed
}
}
// 判断是否过期
const isOverdue = (dueDate) => {
return new Date(dueDate) < new Date()
}
// 获取优先级文本
const getPriorityText = (priority) => {
const priorityMap = {
urgent: '紧急',
high: '高',
medium: '中',
low: '低'
}
return priorityMap[priority] || priority
}
// 格式化截止日期
const formatDueDate = (dueDate) => {
const date = new Date(dueDate)
const today = new Date()
const diffTime = date - today
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays < 0) {
return `逾期${Math.abs(diffDays)}`
} else if (diffDays === 0) {
return '今天到期'
} else if (diffDays === 1) {
return '明天到期'
} else {
return `${diffDays}天后到期`
}
}
// 编辑事项
const editAction = (action) => {
// 这里可以实现编辑功能
console.log('编辑事项:', action)
}
// 删除事项
const deleteAction = (id) => {
if (confirm('确定要删除这个事项吗?')) {
const index = actions.value.findIndex(a => a.id === id)
if (index > -1) {
actions.value.splice(index, 1)
}
}
}
// 添加新事项
const addAction = () => {
const newId = Math.max(...actions.value.map(a => a.id)) + 1
actions.value.push({
id: newId,
...newAction.value,
progress: 0,
tags: [],
completed: false,
createdAt: new Date().toISOString().split('T')[0]
})
// 重置表单
newAction.value = {
title: '',
description: '',
priority: 'medium',
dueDate: '',
relatedGroup: ''
}
showAddForm.value = false
}
</script>
<style lang="scss" scoped>
.action-items {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
height: 100%;
display: flex;
flex-direction: column;
.actions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
h2 {
font-size: 1.2rem;
font-weight: 600;
color: #1e293b;
margin: 0;
}
.header-controls {
display: flex;
gap: 0.75rem;
align-items: center;
.priority-filter {
padding: 0.5rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 0.85rem;
background: white;
cursor: pointer;
&:focus {
outline: none;
border-color: #3b82f6;
}
}
.add-btn {
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: #2563eb;
}
}
}
}
// 统计概览
.actions-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
.summary-item {
text-align: center;
padding: 1rem;
border-radius: 8px;
&.urgent {
background: #fef2f2;
border: 1px solid #fecaca;
}
&.high {
background: #fef3c7;
border: 1px solid #fed7aa;
}
&.medium {
background: #eff6ff;
border: 1px solid #bfdbfe;
}
&.completed {
background: #f0fdf4;
border: 1px solid #bbf7d0;
}
.summary-count {
font-size: 1.5rem;
font-weight: bold;
color: #1f2937;
margin-bottom: 0.25rem;
}
.summary-label {
font-size: 0.8rem;
color: #6b7280;
}
}
}
// 事项列表
.actions-list {
flex: 1;
overflow-y: auto;
.action-item {
display: flex;
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 1rem;
transition: all 0.2s ease;
&:last-child {
margin-bottom: 0;
}
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&.urgent {
border-left: 4px solid #ef4444;
}
&.high {
border-left: 4px solid #f59e0b;
}
&.medium {
border-left: 4px solid #3b82f6;
}
&.low {
border-left: 4px solid #10b981;
}
&.completed {
opacity: 0.6;
background: #f9fafb;
}
&.overdue {
background: #fef2f2;
}
.action-checkbox {
margin-right: 1rem;
.checkbox {
width: 18px;
height: 18px;
cursor: pointer;
}
}
.action-content {
flex: 1;
.action-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
.action-title {
font-size: 1rem;
font-weight: 600;
color: #1f2937;
margin: 0;
&.completed {
text-decoration: line-through;
color: #9ca3af;
}
}
.action-meta {
display: flex;
gap: 0.5rem;
align-items: center;
.priority-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
&.urgent {
background: #fee2e2;
color: #991b1b;
}
&.high {
background: #fef3c7;
color: #92400e;
}
&.medium {
background: #dbeafe;
color: #1e40af;
}
&.low {
background: #dcfce7;
color: #166534;
}
}
.due-date {
font-size: 0.8rem;
color: #6b7280;
&.overdue {
color: #ef4444;
font-weight: 600;
}
}
}
}
.action-description {
font-size: 0.9rem;
color: #6b7280;
margin: 0 0 1rem 0;
line-height: 1.5;
}
.action-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
.detail-item {
display: flex;
align-items: center;
gap: 0.5rem;
.detail-label {
font-size: 0.8rem;
color: #9ca3af;
min-width: 60px;
}
.detail-value {
font-size: 0.8rem;
color: #374151;
font-weight: 500;
}
.progress-mini {
display: flex;
align-items: center;
gap: 0.5rem;
.progress-bar-mini {
width: 60px;
height: 4px;
background: #f3f4f6;
border-radius: 2px;
overflow: hidden;
.progress-fill-mini {
height: 100%;
background: #3b82f6;
transition: width 0.3s ease;
}
}
.progress-text-mini {
font-size: 0.75rem;
color: #6b7280;
}
}
}
}
.action-footer {
display: flex;
justify-content: space-between;
align-items: center;
.action-tags {
display: flex;
gap: 0.5rem;
.tag {
padding: 0.25rem 0.5rem;
background: #f3f4f6;
color: #6b7280;
border-radius: 4px;
font-size: 0.75rem;
}
}
.action-buttons {
display: flex;
gap: 0.5rem;
.btn-edit, .btn-delete {
padding: 0.25rem 0.75rem;
border: none;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-edit {
background: #f3f4f6;
color: #374151;
&:hover {
background: #e5e7eb;
}
}
.btn-delete {
background: #fee2e2;
color: #991b1b;
&:hover {
background: #fecaca;
}
}
}
}
}
}
}
// 空状态
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-text {
h3 {
font-size: 1.1rem;
color: #374151;
margin: 0 0 0.5rem 0;
}
p {
color: #6b7280;
margin: 0;
}
}
}
// 模态框
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
.modal-content {
background: white;
border-radius: 12px;
padding: 1.5rem;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
h3 {
margin: 0;
color: #1f2937;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
&:hover {
color: #374151;
}
}
}
.add-form {
.form-group {
margin-bottom: 1rem;
label {
display: block;
font-size: 0.9rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.5rem;
}
.form-input, .form-textarea, .form-select {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
&:focus {
outline: none;
border-color: #3b82f6;
}
}
.form-textarea {
height: 80px;
resize: vertical;
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
.btn-cancel, .btn-submit {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-cancel {
background: #f3f4f6;
color: #374151;
&:hover {
background: #e5e7eb;
}
}
.btn-submit {
background: #3b82f6;
color: white;
&:hover {
background: #2563eb;
}
}
}
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.action-items {
padding: 1rem;
.actions-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.actions-summary {
grid-template-columns: repeat(2, 1fr);
}
.action-item {
.action-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.action-details {
grid-template-columns: 1fr;
}
.action-footer {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
}
.modal-content {
.form-row {
grid-template-columns: 1fr;
}
}
}
}
</style>

View File

@@ -0,0 +1,246 @@
<template>
<div class="center-overview">
<h2>中心整体概览</h2>
<div class="overview-grid">
<div class="overview-card primary">
<div class="card-header">
<span class="card-title">中心总业绩</span>
<span class="card-trend positive">+12% vs 上期</span>
</div>
<div class="card-value">552,000 </div>
<div class="card-subtitle">月目标完成率: 56%</div>
</div>
<div class="overview-card">
<div class="card-header">
<span class="card-title">活跃组数</span>
<span class="card-trend stable">5/5 </span>
</div>
<div class="card-value">5 </div>
<div class="card-subtitle">总人数: 40</div>
</div>
<div class="overview-card">
<div class="card-header">
<span class="card-title">中心转化率</span>
<span class="card-trend positive">+0.3% vs 上期</span>
</div>
<div class="card-value">5.2%</div>
<div class="card-subtitle">行业平均: 4.8%</div>
</div>
<div class="overview-card">
<div class="card-header">
<span class="card-title">总通话次数</span>
<span class="card-trend positive">+8% vs 上期</span>
</div>
<div class="card-value">1,247 </div>
<div class="card-subtitle">有效通话: 892</div>
</div>
<div class="overview-card">
<div class="card-header">
<span class="card-title">新增客户</span>
<span class="card-trend positive">+15% vs 上期</span>
</div>
<div class="card-value">117 </div>
<div class="card-subtitle">意向客户: 89</div>
</div>
<div class="overview-card">
<div class="card-header">
<span class="card-title">定金转化</span>
<span class="card-trend positive">+18% vs 上期</span>
</div>
<div class="card-value">40 </div>
<div class="card-subtitle">平均定金转化率: 13,800</div>
</div>
</div>
</div>
</template>
<script setup>
// 中心整体概览组件
</script>
<style lang="scss" scoped>
.center-overview {
background: white;
border-radius: 12px;
padding: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
h2 {
font-size: 1.3rem;
font-weight: 600;
color: #1e293b;
margin: 0 0 1.5rem 0;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 0;
}
.overview-card {
padding: 1.2rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
&.primary {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
border: none;
.card-title, .card-subtitle {
color: rgba(255, 255, 255, 0.9);
}
.card-trend {
color: rgba(255, 255, 255, 0.8);
}
.card-value {
color: white;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
// padding: 1rem;
margin-bottom: 0.5rem;
}
.card-title {
color: #64748b;
font-size: 0.9rem;
font-weight: 500;
}
.card-trend {
font-size: 0.75rem;
font-weight: 600;
&.positive {
color: #059669;
}
&.negative {
color: #dc2626;
}
&.stable {
color: #7c3aed;
}
}
.card-value {
font-size: 1.8rem;
font-weight: bold;
color: #1e293b;
margin-bottom: 0.25rem;
}
.card-subtitle {
color: #94a3b8;
font-size: 0.8rem;
}
}
.trend-section {
h3 {
font-size: 1.1rem;
font-weight: 600;
color: #1e293b;
margin: 0 0 1rem 0;
}
.trend-charts {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.trend-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
background: #f8fafc;
border-radius: 8px;
.trend-label {
font-size: 0.85rem;
color: #64748b;
min-width: 80px;
}
.trend-bar {
flex: 1;
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
.trend-fill {
height: 100%;
background: linear-gradient(90deg, #10b981, #059669);
border-radius: 3px;
transition: width 0.3s ease;
}
}
.trend-value {
font-size: 0.8rem;
font-weight: 600;
color: #059669;
min-width: 40px;
text-align: right;
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.center-overview {
padding: 1rem;
.overview-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.overview-card {
padding: 1rem;
.card-value {
font-size: 1.5rem;
}
}
.trend-charts {
grid-template-columns: 1fr;
}
}
}
@media (max-width: 480px) {
.center-overview {
.overview-grid {
grid-template-columns: 1fr;
}
}
}
</style>

View File

@@ -0,0 +1,407 @@
<template>
<div style="width: 47vw;">
<h2 class="section-title">客户详情</h2>
<div id="context-panel" ref="contextPanelRef" class="section-card">
<div v-if="selectedContact" class="context-panel-content" style="min-height: 570px;">
<div class="panel-header">
<h3>{{ selectedContact.name }}</h3>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue';
import Chart from 'chart.js/auto';
// 定义props
const props = defineProps({
selectedContact: {
type: Object,
default: null
}
});
const contextPanelRef = ref(null);
const sentimentChartCanvas = ref(null);
const chartInstances = {};
// CHARTING
const createOrUpdateChart = (chartId, canvasRef, config) => {
if (chartInstances[chartId]) {
chartInstances[chartId].destroy();
}
if (canvasRef.value) {
const ctx = canvasRef.value.getContext('2d');
chartInstances[chartId] = new Chart(ctx, config);
}
};
const renderSentimentChart = (history) => {
if (!sentimentChartCanvas.value) return;
const ctx = sentimentChartCanvas.value.getContext('2d');
const gradient = ctx.createLinearGradient(0, 0, 0, 120);
gradient.addColorStop(0, 'rgba(59, 130, 246, 0.3)');
gradient.addColorStop(1, 'rgba(59, 130, 246, 0.05)');
const config = {
type: 'line',
data: {
labels: history.map((_, i) => `${i+1}`),
datasets: [{
label: '情绪值',
data: history,
borderColor: '#3b82f6',
borderWidth: 3,
tension: 0.4,
fill: true,
backgroundColor: gradient,
pointRadius: 4,
pointBackgroundColor: '#3b82f6',
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: '#3b82f6',
borderWidth: 1,
callbacks: {
label: function(context) {
return `情绪值: ${context.parsed.y}`;
}
}
}
},
scales: {
y: {
display: true,
min: 0,
max: 100,
grid: {
color: 'rgba(148, 163, 184, 0.2)',
drawBorder: false
},
ticks: {
color: '#64748b',
font: {
size: 11
},
stepSize: 25
}
},
x: {
display: true,
grid: {
display: false
},
ticks: {
color: '#64748b',
font: {
size: 11
}
}
}
}
}
};
createOrUpdateChart('sentiment', sentimentChartCanvas, config);
};
// WATCHERS
watch(() => props.selectedContact, (newContact) => {
if (newContact && newContact.sentimentHistory && newContact.sentimentHistory.length > 0) {
nextTick(() => {
renderSentimentChart(newContact.sentimentHistory);
});
}
}, { immediate: true });
</script>
<style lang="scss" scoped>
// Color Palette
$slate-50: #f8fafc;
$slate-100: #f1f5f9;
$slate-200: #e2e8f0;
$slate-400: #94a3b8;
$slate-500: #64748b;
$slate-600: #475569;
$slate-700: #334155;
$slate-800: #1e293b;
$white: #ffffff;
$blue: #3b82f6;
$green: #22c55e;
$amber: #f59e0b;
$red: #ef4444;
$indigo: #4f46e5;
$purple: #a855f7;
h2.section-title {
font-size: 1.25rem;
font-weight: bold;
color: $slate-700;
}
h4 {
font-weight: 600;
color: $slate-700;
margin-bottom: 0.5rem;
}
// Context Panel
.section-card {
background-color: $white;
border-radius: 0.75rem;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
padding-top: 0rem;
padding-left: 1rem;
padding-right: 1rem;
padding-bottom: 1rem;
margin-top: 12px;
}
.context-panel-content {
.panel-header {
border-bottom: 1px solid $slate-200;
padding-bottom: 1rem;
h3 { margin-bottom: 0; font-size: 1.25rem; }
}
.detail-blocks-container {
display: flex;
gap: 1rem;
// 移动端适配
@media (max-width: 768px) {
flex-direction: column;
gap: 1.5rem;
}
}
.detail-column {
display: flex;
flex-direction: column;
gap: 1rem;
&:first-child {
flex: 1; // 左列占1份
}
&:last-child {
flex: 2; // 右列占2份
}
// 移动端适配
@media (max-width: 768px) {
&:first-child,
&:last-child {
flex: 1;
}
}
}
.detail-block {
min-width: 0; // 防止flex项目溢出
}
.form-details {
background-color: $slate-50;
padding: 0.75rem;
border-radius: 0.5rem;
font-size: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
// 移动端适配
@media (max-width: 768px) {
padding: 1rem;
font-size: 0.95rem;
}
> div {
display: flex;
gap: 0.5rem;
&.concerns {
align-items: flex-start;
}
span:last-child {
font-weight: 500;
text-align: right;
}
}
}
.communication-insights {
background-color: $slate-50;
padding: 0.75rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
.insight-item {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
h5 {
font-size: 0.875rem;
font-weight: 600;
color: $slate-700;
margin-bottom: 0.5rem;
}
}
.suggestion-tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.suggestion-tag {
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
&.positive {
background-color: #dcfce7;
color: #166534;
}
}
.ratio-chart {
.ratio-bar {
display: flex;
height: 1.5rem;
border-radius: 0.375rem;
overflow: hidden;
margin-bottom: 0.5rem;
.speak-portion {
background-color: #3b82f6;
transition: width 0.3s ease;
}
.listen-portion {
background-color: #22c55e;
transition: width 0.3s ease;
}
}
.ratio-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
.speak-label {
color: #3b82f6;
font-weight: 500;
}
.listen-label {
color: #22c55e;
font-weight: 500;
}
}
}
}
.sentiment-summary {
background-color: $slate-50;
padding: 0.75rem;
border-radius: 0.5rem;
p { font-size: 0.875rem; font-weight: 500; span { font-weight: 400; } }
.sentiment-chart-block {
margin-top: 0.75rem;
padding: 0.75rem;
background-color: $white;
border-radius: 0.5rem;
border: 1px solid $slate-200;
p {
margin-bottom: 0.5rem;
font-weight: 500;
color: $slate-700;
}
}
}
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
color: $slate-500;
}
// Timeline
.timeline {
border-left: 2px solid $slate-200;
.timeline-item {
position: relative;
padding-left: 1rem;
padding-bottom: 1rem;
&:last-child {
padding-bottom: 0;
}
&::before {
content: '';
position: absolute;
left: -6px;
top: 5px;
width: 10px;
height: 10px;
border-radius: 9999px;
background-color: $slate-400;
border: 2px solid $white;
}
&.call::before { background-color: $blue; }
&.email::before { background-color: $green; }
&.meeting::before { background-color: $purple; }
&.system::before { background-color: $slate-500; }
}
.timeline-date { font-size: 0.75rem; color: $slate-400; }
.timeline-summary { font-size: 0.875rem; color: $slate-600; }
.no-interactions { padding-left: 1rem; font-size: 0.875rem; color: $slate-400; }
}
// Tags
.tags-container {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.625rem;
border-radius: 9999px;
&.concern-tag { background-color: $slate-200; color: $slate-700; }
}
// Chart Containers
.chart-container {
position: relative;
width: 100%;
height: 180px;
&.sentiment-chart {
height: 120px;
margin-top: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="chart-container">
<div class="chart-header">
<h3>客户类型占比</h3>
<select v-model="customerTypeCategory" @change="updateChart" class="chart-select">
<option value="age">年龄</option>
<option value="profession">职业</option>
<option value="childGrade">孩子年级</option>
<option value="region">地域</option>
</select>
</div>
<div class="chart-content">
<div ref="customerTypeChartRef" class="customer-type-chart"></div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue';
import * as echarts from 'echarts';
const customerTypeChartRef = ref(null);
let customerTypeChart = null;
const customerTypeCategory = ref('age');
const customerTypeData = reactive({
age: [{ value: 120, name: '18-25岁' }, { value: 200, name: '26-35岁' }, { value: 150, name: '36-45岁' }, { value: 80, name: '46-55岁' }, { value: 50, name: '55岁以上' }],
profession: [{ value: 180, name: '企业管理者' }, { value: 120, name: '教师' }, { value: 100, name: '医生' }, { value: 90, name: '工程师' }, { value: 110, name: '其他' }],
childGrade: [{ value: 80, name: '幼儿园' }, { value: 150, name: '小学' }, { value: 180, name: '初中' }, { value: 120, name: '高中' }, { value: 70, name: '大学' }],
region: [{ value: 200, name: '北京' }, { value: 150, name: '上海' }, { value: 120, name: '广州' }, { value: 100, name: '深圳' }, { value: 130, name: '其他' }]
});
const updateChart = () => {
if (!customerTypeChart) return;
const currentData = customerTypeData[customerTypeCategory.value];
const option = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: currentData.map(item => item.name), axisTick: { alignWithLabel: true } },
yAxis: { type: 'value' },
series: [{
name: '客户数量', type: 'bar', barWidth: '60%',
data: currentData.map(item => item.value),
itemStyle: {
color: (params) => ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de'][params.dataIndex % 5]
}
}]
};
customerTypeChart.setOption(option, true);
};
const initChart = () => {
if (!customerTypeChartRef.value) return;
customerTypeChart = echarts.init(customerTypeChartRef.value);
updateChart();
};
const resizeChart = () => customerTypeChart?.resize();
onMounted(() => {
initChart();
window.addEventListener('resize', resizeChart);
});
onBeforeUnmount(() => {
customerTypeChart?.dispose();
window.removeEventListener('resize', resizeChart);
});
</script>
<style lang="scss" scoped>
.chart-container {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
height: 400px;
display: flex;
flex-direction: column;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 16px;
border-bottom: 1px solid #ebeef5;
h3 { margin: 0; color: #303133; font-size: 18px; font-weight: 600; }
}
.chart-content {
padding: 20px;
flex-grow: 1;
position: relative;
}
.customer-type-chart {
width: 100%;
height: 300px;
}
.chart-select {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #e2e8f0;
background-color: #f8fafc;
font-size: 14px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,402 @@
<template>
<div class="group-comparison">
<!-- 综合排名 -->
<div class="ranking-section">
<div class="ranking-header">
<h3>综合表现排名</h3>
<div class="manager-selector">
<select v-model="selectedManager" @change="handleManagerChange" class="manager-dropdown">
<option value="all">全部高级经理</option>
<option v-for="manager in seniorManagers" :key="manager.id" :value="manager.id">
{{ manager.name }}
</option>
</select>
</div>
</div>
<div class="ranking-grid compact">
<div
v-for="(group, index) in sortedGroups"
:key="group.id"
class="ranking-card"
:class="getRankingClass(index)"
@click="$emit('select-group', group)"
>
<div class="rank-badge">{{ index + 1 }}</div>
<div class="group-info">
<div class="group-name">{{ group.name }}</div>
<div class="group-leader">{{ group.leader }}</div>
</div>
<div class="performance-score">
<div class="score">{{ calculateScore(group) }}</div>
<div class="score-label">综合分</div>
</div>
<div class="key-metrics">
<div class="mini-metric">
<span class="mini-label">业绩</span>
<span class="mini-value">{{ formatCurrency(group.todayPerformance) }}</span>
</div>
<div class="mini-metric">
<span class="mini-label">转化</span>
<span class="mini-value">{{ group.conversionRate }}%</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
groups: {
type: Array,
required: true
}
})
const emit = defineEmits(['select-group', 'manager-change'])
// 选中的高级经理
const selectedManager = ref('all')
// 高级经理列表
const seniorManagers = ref([
{ id: 'manager1', name: '张经理' },
{ id: 'manager2', name: '李经理' },
{ id: 'manager3', name: '王经理' },
{ id: 'manager4', name: '刘经理' },
{ id: 'manager5', name: '陈经理' }
])
// 处理经理选择变化
const handleManagerChange = () => {
emit('manager-change', selectedManager.value)
}
// 按综合表现排序的组别
const sortedGroups = computed(() => {
return [...props.groups].sort((a, b) => calculateScore(b) - calculateScore(a))
})
// 计算综合分数
const calculateScore = (group) => {
const performanceScore = (group.todayPerformance / 200000) * 30
const conversionScore = (group.conversionRate / 10) * 25
const clientScore = (group.newClients / 50) * 25
const dealScore = (group.deals / 20) * 20
return Math.round(performanceScore + conversionScore + clientScore + dealScore)
}
// 格式化指标值
const formatMetricValue = (value, unit) => {
switch (unit) {
case 'currency':
return formatCurrency(value)
case 'percent':
return value + '%'
case 'number':
return value.toString()
default:
return value
}
}
// 格式化货币
const formatCurrency = (value) => {
if (value >= 10000) {
return (value / 10000).toFixed(1) + '万'
}
return value.toLocaleString()
}
// 获取进度条宽度
const getProgressWidth = (value, metricKey) => {
const maxValues = {
todayPerformance: 200000,
conversionRate: 10,
newClients: 50,
deals: 20
}
return Math.min((value / maxValues[metricKey]) * 100, 100)
}
// 获取表现等级样式
const getPerformanceClass = (value, metricKey) => {
const thresholds = {
todayPerformance: { excellent: 120000, good: 80000 },
conversionRate: { excellent: 6, good: 4 },
newClients: { excellent: 25, good: 15 },
deals: { excellent: 8, good: 5 }
}
const threshold = thresholds[metricKey]
if (value >= threshold.excellent) return 'excellent'
if (value >= threshold.good) return 'good'
return 'poor'
}
// 获取排名样式
const getRankingClass = (index) => {
if (index === 0) return 'rank-1'
if (index === 1) return 'rank-2'
if (index === 2) return 'rank-3'
return 'rank-other'
}
// 获取趋势图标
const getTrendIcon = (trend) => {
const icons = {
up: '↗',
down: '↘',
stable: '→'
}
return icons[trend] || '→'
}
// 获取预警图标
const getAlertIcon = (level) => {
const icons = {
warning: '⚠️',
info: '',
urgent: '🚨'
}
return icons[level] || ''
}
</script>
<style lang="scss" scoped>
.group-comparison {
background: white;
border-radius: 12px;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
height: 24rem;
overflow: auto;
// 综合排名
.ranking-section {
// margin-bottom: 2rem;
.ranking-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
h3 {
font-size: 1.1rem;
font-weight: 600;
color: #374151;
margin: 0;
}
.manager-selector {
.manager-dropdown {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
font-size: 0.875rem;
color: #374151;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: #9ca3af;
}
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
}
}
.ranking-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
&.compact {
grid-template-columns: repeat(1, 1fr);
gap: 0.75rem;
.ranking-card {
padding: 0.75rem;
gap: 0.5rem;
.rank-badge {
width: 24px;
height: 24px;
font-size: 0.8rem;
}
.group-info {
.group-name {
font-size: 0.9rem;
}
.group-leader {
font-size: 0.75rem;
}
}
.performance-score {
.score {
font-size: 1.1rem;
}
.score-label {
font-size: 0.7rem;
}
}
.key-metrics {
.mini-metric {
.mini-label {
font-size: 0.7rem;
}
.mini-value {
font-size: 0.8rem;
}
}
}
}
}
}
.ranking-card {
display: flex;
align-items: center;
padding: 1rem;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.rank-1 {
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
border: 2px solid #f59e0b;
}
&.rank-2 {
background: linear-gradient(135deg, #c0c0c0 0%, #e5e7eb 100%);
border: 2px solid #9ca3af;
}
&.rank-3 {
background: linear-gradient(135deg, #cd7f32 0%, #d97706 100%);
border: 2px solid #b45309;
}
&.rank-other {
background: white;
border: 1px solid #e5e7eb;
}
.rank-badge {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.2rem;
margin-right: 1rem;
background: rgba(255, 255, 255, 0.9);
color: #1f2937;
}
.group-info {
flex: 1;
.group-name {
font-weight: 600;
color: #1f2937;
margin-bottom: 0.25rem;
}
.group-leader {
font-size: 0.85rem;
color: #6b7280;
}
}
.performance-score {
text-align: center;
margin: 0 1rem;
.score {
font-size: 1.5rem;
font-weight: bold;
color: #1f2937;
}
.score-label {
font-size: 0.75rem;
color: #6b7280;
}
}
.key-metrics {
display: flex;
flex-direction: column;
gap: 0.25rem;
.mini-metric {
display: flex;
justify-content: space-between;
gap: 0.5rem;
.mini-label {
font-size: 0.75rem;
color: #6b7280;
}
.mini-value {
font-size: 0.75rem;
font-weight: 600;
color: #1f2937;
}
}
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.group-comparison {
padding: 0.75rem;
.ranking-grid {
grid-template-columns: 1fr;
&.compact {
grid-template-columns: 1fr;
}
}
.ranking-card {
.key-metrics {
display: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,331 @@
<template>
<div class="group-ranking">
<div class="ranking-header">
<h2>各阶段转化率 vs. 公司平均</h2>
<div class="legend">
<div class="legend-item">
<div class="legend-color team"></div>
<span>本团队</span>
</div>
<div class="legend-item">
<div class="legend-color company"></div>
<span>公司平均</span>
</div>
</div>
</div>
<div class="chart-container">
<div class="chart">
<div class="chart-main">
<div class="y-axis">
<div class="y-label" v-for="label in yAxisLabels" :key="label">{{ label }}</div>
</div>
<div class="chart-content">
<div class="chart-item" v-for="stage in conversionStages" :key="stage.name">
<div class="bars">
<div class="bar-group">
<div class="bar-container">
<div class="bar-value team-value">{{ stage.teamRate }}%</div>
<div
class="bar team-bar"
:style="{ height: (stage.teamRate * 2.4) + 'px' }"
:title="`本团队: ${stage.teamRate}%`"
></div>
</div>
<div class="bar-container">
<div class="bar-value company-value">{{ stage.companyRate }}%</div>
<div
class="bar company-bar"
:style="{ height: (stage.companyRate * 2.4) + 'px' }"
:title="`公司平均: ${stage.companyRate}%`"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- X轴刻度 -->
<div class="x-axis">
<div class="x-label" v-for="stage in conversionStages" :key="stage.name">
{{ stage.name }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
selectedGroup: {
type: Object,
default: null
}
})
// 转化率数据
const conversionStages = ref([
{
name: '加微',
teamRate: 80,
companyRate: 85
},
{
name: '填表',
teamRate: 90,
companyRate: 92
},
{
name: '通话',
teamRate: 95,
companyRate: 95
},
{
name: '首课',
teamRate: 60,
companyRate: 65
},
{
name: '三课',
teamRate: 85,
companyRate: 88
},
{
name: '付费',
teamRate: 15,
companyRate: 20
}
])
// Y轴标签
const yAxisLabels = ref(['100%', '80%', '60%', '40%', '20%', '0%'])
</script>
<style lang="scss" scoped>
.group-ranking {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
height: 23rem;
display: flex;
flex-direction: column;
.ranking-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
h2 {
font-size: 1.2rem;
font-weight: 600;
color: #1e293b;
margin: 0;
}
.legend {
display: flex;
gap: 1rem;
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: #64748b;
.legend-color {
width: 16px;
height: 16px;
border-radius: 4px;
&.team {
background: #3b82f6;
}
&.company {
background: #94a3b8;
}
}
}
}
}
.chart-container {
flex: 1;
.chart {
display: flex;
flex-direction: column;
height: 320px;
.chart-main {
display: flex;
flex: 1;
}
.y-axis {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 40px;
padding-right: 10px;
.y-label {
font-size: 0.8rem;
color: #64748b;
text-align: right;
}
}
.chart-content {
flex: 1;
display: flex;
align-items: flex-end;
justify-content: space-around;
border-left: 1px solid #e2e8f0;
border-bottom: 1px solid #e2e8f0;
padding: 0 1rem 0 1rem;
position: relative;
.chart-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
.bars {
height: 240px;
display: flex;
align-items: flex-end;
justify-content: center;
.bar-group {
display: flex;
gap: 1px;
align-items: flex-end;
.bar-container {
display: flex;
flex-direction: column;
align-items: center;
.bar-value {
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 4px;
min-height: 16px;
&.team-value {
color: #3b82f6;
}
&.company-value {
color: #64748b;
}
}
.bar {
width: 20px;
min-height: 2px;
border-radius: 2px 2px 0 0;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
opacity: 0.8;
}
&.team-bar {
background: #3b82f6;
}
&.company-bar {
background: #94a3b8;
}
}
}
}
}
}
}
.x-axis {
display: flex;
padding: 0.5rem 0 0 41px;
.x-label {
flex: 1;
text-align: center;
font-size: 0.9rem;
color: #374151;
font-weight: 500;
}
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.group-ranking {
.ranking-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
h2 {
font-size: 1.1rem;
}
}
.chart-container {
.chart {
height: 270px;
.chart-main .chart-content {
.chart-item {
.bars {
height: 180px;
.bar-group {
gap: 6px;
.bar-container {
.bar-value {
font-size: 0.7rem;
}
.bar {
width: 16px;
}
}
}
}
}
}
.x-axis {
padding: 0.5rem 0 0 31px;
.x-label {
font-size: 0.8rem;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,222 @@
<template>
<div class="chart-container">
<div class="chart-header">
<h3>客户迫切解决的问题排行榜</h3>
</div>
<div class="chart-content">
<div v-if="sortedData.length > 0" class="problem-ranking">
<div
v-for="(item, index) in sortedData"
:key="item.name"
class="ranking-item"
:class="getRankingClass(index)"
>
<div class="rank-number">
<span class="rank-badge" :class="getRankBadgeClass(index)">{{ index + 1 }}</span>
</div>
<div class="problem-info">
<div class="problem-name">{{ item.name }}</div>
</div>
<div class="problem-percentage">
<span class="percentage">{{ getPercentage(item.value) }}%</span>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: getPercentage(item.value) + '%' }"></div>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<p>暂无排行榜数据</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
// 定义Props接收一个包含 { name: string, value: string | number } 的数组
const props = defineProps({
rankingData: {
type: Array,
default: () => [] // 默认值为空数组
}
});
// --- 计算属性 ---
// 对传入的数据进行排序
const sortedData = computed(() => {
if (Array.isArray(props.rankingData) && props.rankingData.length > 0) {
// 创建一个副本进行排序,以避免直接修改 props
return [...props.rankingData].sort((a, b) => {
// 统一将值转换为数字进行比较
const aValue = parseFloat(String(a.value).replace('%', '')) || 0;
const bValue = parseFloat(String(b.value).replace('%', '')) || 0;
return bValue - aValue;
});
}
return []; // 如果没有有效数据,返回空数组
});
// --- 辅助方法 ---
// 获取百分比数值,兼容 "55%" 和 55 两种格式
const getPercentage = (value) => {
return parseFloat(String(value).replace('%', '')) || 0;
};
// 根据排名索引返回不同的CSS类
const getRankingClass = (index) => {
return ['rank-first', 'rank-second', 'rank-third'][index] || 'rank-other';
};
// 根据排名索引返回徽章的CSS类
const getRankBadgeClass = (index) => {
return ['badge-gold', 'badge-silver', 'badge-bronze'][index] || 'badge-default';
};
</script>
<style lang="scss" scoped>
/* 外部容器样式 */
.chart-container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
height: 400px; /* 保持与其他卡片高度一致 */
display: flex;
flex-direction: column;
}
.chart-header {
padding: 20px 20px 16px;
border-bottom: 1px solid #ebeef5;
h3 {
margin: 0;
color: #303133;
font-size: 18px;
font-weight: 600;
}
}
.chart-content {
padding: 20px;
flex-grow: 1;
overflow: hidden; /* 防止内容溢出 */
}
/* 排行榜核心样式 */
.problem-ranking {
overflow-y: auto;
/* 自定义滚动条样式 (可选) */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
}
}
.ranking-item {
display: flex;
align-items: center;
padding: 12px 16px;
margin-bottom: 8px;
border-radius: 8px;
background: #f8f9fa;
border: 1px solid #e9ecef;
transition: all 0.3s ease;
&:last-child {
margin-bottom: 0;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
.rank-number {
margin-right: 16px;
}
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
font-weight: bold;
font-size: 14px;
color: white;
flex-shrink: 0;
&.badge-gold {
background: linear-gradient(135deg, #ffd700, #ffb300);
}
&.badge-silver {
background: linear-gradient(135deg, #c0c0c0, #a8a8a8);
}
&.badge-bronze {
background: linear-gradient(135deg, #cd7f32, #b8860b);
}
&.badge-default {
background: linear-gradient(135deg, #6c757d, #495057);
}
}
.problem-info {
flex: 1;
margin-right: 16px;
}
.problem-name {
font-size: 16px;
font-weight: 600;
color: #212529;
}
.problem-percentage {
display: flex;
flex-direction: column;
align-items: flex-end;
min-width: 80px;
}
.percentage {
font-size: 16px;
font-weight: bold;
color: #495057;
margin-bottom: 4px;
}
.progress-bar {
width: 60px;
height: 6px;
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
.rank-first & { background: linear-gradient(90deg, #ffd700, #ffb300); }
.rank-second & { background: linear-gradient(90deg, #c0c0c0, #a8a8a8); }
.rank-third & { background: linear-gradient(90deg, #cd7f32, #b8860b); }
.rank-other & { background: linear-gradient(90deg, #007bff, #0056b3); }
}
/* 空状态样式 */
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #909399;
font-size: 16px;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,245 @@
<template>
<div class="center-overview">
<h2>整体概览</h2>
<div class="overview-grid">
<div class="overview-card primary">
<div class="card-header">
<span class="card-title">中心总业绩</span>
<span class="card-trend positive">+12% vs 昨日</span>
</div>
<div class="card-value">552,000 </div>
<div class="card-subtitle">月目标完成率: 56%</div>
</div>
<div class="overview-card">
<div class="card-header">
<span class="card-title">活跃组数</span>
<span class="card-trend stable">5/5 </span>
</div>
<div class="card-value">5 </div>
<div class="card-subtitle">总人数: 40</div>
</div>
<div class="overview-card">
<div class="card-header">
<span class="card-title">中心转化率</span>
<span class="card-trend positive">+0.3% vs 上期</span>
</div>
<div class="card-value">5.2%</div>
<div class="card-subtitle">行业平均: 4.8%</div>
</div>
<div class="overview-card">
<div class="card-header">
<span class="card-title">总通话次数</span>
<span class="card-trend positive">+8% vs 上期</span>
</div>
<div class="card-value">1,247 </div>
<div class="card-subtitle">有效通话: 892</div>
</div>
<div class="overview-card">
<div class="card-header">
<span class="card-title">新增客户</span>
<span class="card-trend positive">+15% vs 上期</span>
</div>
<div class="card-value">117 </div>
<div class="card-subtitle">意向客户: 89</div>
</div>
<div class="overview-card">
<div class="card-header">
<span class="card-title">定金转化</span>
<span class="card-trend positive">+18% vs 上期</span>
</div>
<div class="card-value">40 </div>
<div class="card-subtitle">本月定金转化率: 10%</div>
</div>
</div>
</div>
</template>
<script setup>
// 中心整体概览组件
</script>
<style lang="scss" scoped>
.center-overview {
background: white;
border-radius: 12px;
padding: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
h2 {
font-size: 1.3rem;
font-weight: 600;
color: #1e293b;
margin: 0 0 1.5rem 0;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 0;
}
.overview-card {
padding: 0.5rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
&.primary {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
border: none;
.card-title, .card-subtitle {
color: rgba(255, 255, 255, 0.9);
}
.card-trend {
color: rgba(255, 255, 255, 0.8);
}
.card-value {
color: white;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.card-title {
color: #64748b;
font-size: 0.9rem;
font-weight: 500;
}
.card-trend {
font-size: 0.75rem;
font-weight: 600;
&.positive {
color: #059669;
}
&.negative {
color: #dc2626;
}
&.stable {
color: #7c3aed;
}
}
.card-value {
font-size: 1.8rem;
font-weight: bold;
color: #1e293b;
margin-bottom: 0.25rem;
}
.card-subtitle {
color: #94a3b8;
font-size: 0.8rem;
}
}
.trend-section {
h3 {
font-size: 1.1rem;
font-weight: 600;
color: #1e293b;
margin: 0 0 1rem 0;
}
.trend-charts {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.trend-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
background: #f8fafc;
border-radius: 8px;
.trend-label {
font-size: 0.85rem;
color: #64748b;
min-width: 80px;
}
.trend-bar {
flex: 1;
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
.trend-fill {
height: 100%;
background: linear-gradient(90deg, #10b981, #059669);
border-radius: 3px;
transition: width 0.3s ease;
}
}
.trend-value {
font-size: 0.8rem;
font-weight: 600;
color: #059669;
min-width: 40px;
text-align: right;
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.center-overview {
padding: 1rem;
.overview-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.overview-card {
padding: 1rem;
.card-value {
font-size: 1.5rem;
}
}
.trend-charts {
grid-template-columns: 1fr;
}
}
}
@media (max-width: 480px) {
.center-overview {
.overview-grid {
grid-template-columns: 1fr;
}
}
}
</style>

View File

@@ -0,0 +1,407 @@
<template>
<div style="width: 47vw;">
<h2 class="section-title">客户详情</h2>
<div id="context-panel" ref="contextPanelRef" class="section-card">
<div v-if="selectedContact" class="context-panel-content" style="min-height: 570px;">
<div class="panel-header">
<h3>{{ selectedContact.name }}</h3>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue';
import Chart from 'chart.js/auto';
// 定义props
const props = defineProps({
selectedContact: {
type: Object,
default: null
}
});
const contextPanelRef = ref(null);
const sentimentChartCanvas = ref(null);
const chartInstances = {};
// CHARTING
const createOrUpdateChart = (chartId, canvasRef, config) => {
if (chartInstances[chartId]) {
chartInstances[chartId].destroy();
}
if (canvasRef.value) {
const ctx = canvasRef.value.getContext('2d');
chartInstances[chartId] = new Chart(ctx, config);
}
};
const renderSentimentChart = (history) => {
if (!sentimentChartCanvas.value) return;
const ctx = sentimentChartCanvas.value.getContext('2d');
const gradient = ctx.createLinearGradient(0, 0, 0, 120);
gradient.addColorStop(0, 'rgba(59, 130, 246, 0.3)');
gradient.addColorStop(1, 'rgba(59, 130, 246, 0.05)');
const config = {
type: 'line',
data: {
labels: history.map((_, i) => `${i+1}`),
datasets: [{
label: '情绪值',
data: history,
borderColor: '#3b82f6',
borderWidth: 3,
tension: 0.4,
fill: true,
backgroundColor: gradient,
pointRadius: 4,
pointBackgroundColor: '#3b82f6',
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: '#3b82f6',
borderWidth: 1,
callbacks: {
label: function(context) {
return `情绪值: ${context.parsed.y}`;
}
}
}
},
scales: {
y: {
display: true,
min: 0,
max: 100,
grid: {
color: 'rgba(148, 163, 184, 0.2)',
drawBorder: false
},
ticks: {
color: '#64748b',
font: {
size: 11
},
stepSize: 25
}
},
x: {
display: true,
grid: {
display: false
},
ticks: {
color: '#64748b',
font: {
size: 11
}
}
}
}
}
};
createOrUpdateChart('sentiment', sentimentChartCanvas, config);
};
// WATCHERS
watch(() => props.selectedContact, (newContact) => {
if (newContact && newContact.sentimentHistory && newContact.sentimentHistory.length > 0) {
nextTick(() => {
renderSentimentChart(newContact.sentimentHistory);
});
}
}, { immediate: true });
</script>
<style lang="scss" scoped>
// Color Palette
$slate-50: #f8fafc;
$slate-100: #f1f5f9;
$slate-200: #e2e8f0;
$slate-400: #94a3b8;
$slate-500: #64748b;
$slate-600: #475569;
$slate-700: #334155;
$slate-800: #1e293b;
$white: #ffffff;
$blue: #3b82f6;
$green: #22c55e;
$amber: #f59e0b;
$red: #ef4444;
$indigo: #4f46e5;
$purple: #a855f7;
h2.section-title {
font-size: 1.25rem;
font-weight: bold;
color: $slate-700;
}
h4 {
font-weight: 600;
color: $slate-700;
margin-bottom: 0.5rem;
}
// Context Panel
.section-card {
background-color: $white;
border-radius: 0.75rem;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
padding-top: 0rem;
padding-left: 1rem;
padding-right: 1rem;
padding-bottom: 1rem;
margin-top: 12px;
}
.context-panel-content {
.panel-header {
border-bottom: 1px solid $slate-200;
padding-bottom: 1rem;
h3 { margin-bottom: 0; font-size: 1.25rem; }
}
.detail-blocks-container {
display: flex;
gap: 1rem;
// 移动端适配
@media (max-width: 768px) {
flex-direction: column;
gap: 1.5rem;
}
}
.detail-column {
display: flex;
flex-direction: column;
gap: 1rem;
&:first-child {
flex: 1; // 左列占1份
}
&:last-child {
flex: 2; // 右列占2份
}
// 移动端适配
@media (max-width: 768px) {
&:first-child,
&:last-child {
flex: 1;
}
}
}
.detail-block {
min-width: 0; // 防止flex项目溢出
}
.form-details {
background-color: $slate-50;
padding: 0.75rem;
border-radius: 0.5rem;
font-size: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
// 移动端适配
@media (max-width: 768px) {
padding: 1rem;
font-size: 0.95rem;
}
> div {
display: flex;
gap: 0.5rem;
&.concerns {
align-items: flex-start;
}
span:last-child {
font-weight: 500;
text-align: right;
}
}
}
.communication-insights {
background-color: $slate-50;
padding: 0.75rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
.insight-item {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
h5 {
font-size: 0.875rem;
font-weight: 600;
color: $slate-700;
margin-bottom: 0.5rem;
}
}
.suggestion-tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.suggestion-tag {
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
&.positive {
background-color: #dcfce7;
color: #166534;
}
}
.ratio-chart {
.ratio-bar {
display: flex;
height: 1.5rem;
border-radius: 0.375rem;
overflow: hidden;
margin-bottom: 0.5rem;
.speak-portion {
background-color: #3b82f6;
transition: width 0.3s ease;
}
.listen-portion {
background-color: #22c55e;
transition: width 0.3s ease;
}
}
.ratio-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
.speak-label {
color: #3b82f6;
font-weight: 500;
}
.listen-label {
color: #22c55e;
font-weight: 500;
}
}
}
}
.sentiment-summary {
background-color: $slate-50;
padding: 0.75rem;
border-radius: 0.5rem;
p { font-size: 0.875rem; font-weight: 500; span { font-weight: 400; } }
.sentiment-chart-block {
margin-top: 0.75rem;
padding: 0.75rem;
background-color: $white;
border-radius: 0.5rem;
border: 1px solid $slate-200;
p {
margin-bottom: 0.5rem;
font-weight: 500;
color: $slate-700;
}
}
}
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
color: $slate-500;
}
// Timeline
.timeline {
border-left: 2px solid $slate-200;
.timeline-item {
position: relative;
padding-left: 1rem;
padding-bottom: 1rem;
&:last-child {
padding-bottom: 0;
}
&::before {
content: '';
position: absolute;
left: -6px;
top: 5px;
width: 10px;
height: 10px;
border-radius: 9999px;
background-color: $slate-400;
border: 2px solid $white;
}
&.call::before { background-color: $blue; }
&.email::before { background-color: $green; }
&.meeting::before { background-color: $purple; }
&.system::before { background-color: $slate-500; }
}
.timeline-date { font-size: 0.75rem; color: $slate-400; }
.timeline-summary { font-size: 0.875rem; color: $slate-600; }
.no-interactions { padding-left: 1rem; font-size: 0.875rem; color: $slate-400; }
}
// Tags
.tags-container {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.625rem;
border-radius: 9999px;
&.concern-tag { background-color: $slate-200; color: $slate-700; }
}
// Chart Containers
.chart-container {
position: relative;
width: 100%;
height: 180px;
&.sentiment-chart {
height: 120px;
margin-top: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,346 @@
<template>
<div class="group-comparison">
<!-- 综合排名 -->
<div class="ranking-section">
<h3>综合表现排名</h3>
<div class="ranking-grid compact">
<div
v-for="(group, index) in sortedGroups"
:key="group.id"
class="ranking-card"
:class="getRankingClass(index)"
@click="$emit('select-group', group)"
>
<div class="rank-badge">{{ index + 1 }}</div>
<div class="group-info">
<div class="group-name">{{ group.name }}</div>
<div class="group-leader">{{ group.leader }}</div>
</div>
<div class="performance-score">
<div class="score">{{ calculateScore(group) }}</div>
<div class="score-label">综合分</div>
</div>
<div class="key-metrics">
<div class="mini-metric">
<span class="mini-label">业绩</span>
<span class="mini-value">{{ formatCurrency(group.todayPerformance) }}</span>
</div>
<div class="mini-metric">
<span class="mini-label">转化</span>
<span class="mini-value">{{ group.conversionRate }}%</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
groups: {
type: Array,
required: true
}
})
const emit = defineEmits(['select-group'])
// 按综合表现排序的组别
const sortedGroups = computed(() => {
return [...props.groups].sort((a, b) => calculateScore(b) - calculateScore(a))
})
// 计算综合分数
const calculateScore = (group) => {
const performanceScore = (group.todayPerformance / 200000) * 30
const conversionScore = (group.conversionRate / 10) * 25
const clientScore = (group.newClients / 50) * 25
const dealScore = (group.deals / 20) * 20
return Math.round(performanceScore + conversionScore + clientScore + dealScore)
}
// 格式化指标值
const formatMetricValue = (value, unit) => {
switch (unit) {
case 'currency':
return formatCurrency(value)
case 'percent':
return value + '%'
case 'number':
return value.toString()
default:
return value
}
}
// 格式化货币
const formatCurrency = (value) => {
if (value >= 10000) {
return (value / 10000).toFixed(1) + '万'
}
return value.toLocaleString()
}
// 获取进度条宽度
const getProgressWidth = (value, metricKey) => {
const maxValues = {
todayPerformance: 200000,
conversionRate: 10,
newClients: 50,
deals: 20
}
return Math.min((value / maxValues[metricKey]) * 100, 100)
}
// 获取表现等级样式
const getPerformanceClass = (value, metricKey) => {
const thresholds = {
todayPerformance: { excellent: 120000, good: 80000 },
conversionRate: { excellent: 6, good: 4 },
newClients: { excellent: 25, good: 15 },
deals: { excellent: 8, good: 5 }
}
const threshold = thresholds[metricKey]
if (value >= threshold.excellent) return 'excellent'
if (value >= threshold.good) return 'good'
return 'poor'
}
// 获取排名样式
const getRankingClass = (index) => {
if (index === 0) return 'rank-1'
if (index === 1) return 'rank-2'
if (index === 2) return 'rank-3'
return 'rank-other'
}
// 获取趋势图标
const getTrendIcon = (trend) => {
const icons = {
up: '↗',
down: '↘',
stable: '→'
}
return icons[trend] || '→'
}
// 获取预警图标
const getAlertIcon = (level) => {
const icons = {
warning: '⚠️',
info: '',
urgent: '🚨'
}
return icons[level] || ''
}
</script>
<style lang="scss" scoped>
.group-comparison {
background: white;
border-radius: 12px;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
height: 24rem;
overflow: auto;
// 综合排名
.ranking-section {
// margin-bottom: 2rem;
h3 {
font-size: 1.1rem;
font-weight: 600;
color: #374151;
margin: 0 0 1rem 0;
}
.ranking-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
&.compact {
grid-template-columns: repeat(1, 1fr);
gap: 0.75rem;
.ranking-card {
padding: 0.75rem;
gap: 0.5rem;
.rank-badge {
width: 24px;
height: 24px;
font-size: 0.8rem;
}
.group-info {
.group-name {
font-size: 0.9rem;
}
.group-leader {
font-size: 0.75rem;
}
}
.performance-score {
.score {
font-size: 1.1rem;
}
.score-label {
font-size: 0.7rem;
}
}
.key-metrics {
.mini-metric {
.mini-label {
font-size: 0.7rem;
}
.mini-value {
font-size: 0.8rem;
}
}
}
}
}
}
.ranking-card {
display: flex;
align-items: center;
padding: 1rem;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.rank-1 {
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
border: 2px solid #f59e0b;
}
&.rank-2 {
background: linear-gradient(135deg, #c0c0c0 0%, #e5e7eb 100%);
border: 2px solid #9ca3af;
}
&.rank-3 {
background: linear-gradient(135deg, #cd7f32 0%, #d97706 100%);
border: 2px solid #b45309;
}
&.rank-other {
background: white;
border: 1px solid #e5e7eb;
}
.rank-badge {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.2rem;
margin-right: 1rem;
background: rgba(255, 255, 255, 0.9);
color: #1f2937;
}
.group-info {
flex: 1;
.group-name {
font-weight: 600;
color: #1f2937;
margin-bottom: 0.25rem;
}
.group-leader {
font-size: 0.85rem;
color: #6b7280;
}
}
.performance-score {
text-align: center;
margin: 0 1rem;
.score {
font-size: 1.5rem;
font-weight: bold;
color: #1f2937;
}
.score-label {
font-size: 0.75rem;
color: #6b7280;
}
}
.key-metrics {
display: flex;
flex-direction: column;
gap: 0.25rem;
.mini-metric {
display: flex;
justify-content: space-between;
gap: 0.5rem;
.mini-label {
font-size: 0.75rem;
color: #6b7280;
}
.mini-value {
font-size: 0.75rem;
font-weight: 600;
color: #1f2937;
}
}
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.group-comparison {
padding: 0.75rem;
.ranking-grid {
grid-template-columns: 1fr;
&.compact {
grid-template-columns: 1fr;
}
}
.ranking-card {
.key-metrics {
display: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,192 @@
<template>
<div class="group-ranking">
<!-- 销售漏斗 -->
<div class="chart-container">
<div class="chart-header">
<h3>销售漏斗</h3>
</div>
<div class="chart-content">
<canvas ref="personalFunnelChartCanvas"></canvas>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import Chart from 'chart.js/auto'
const props = defineProps({
selectedGroup: {
type: Object,
default: null
}
})
// Chart.js 实例
const chartInstances = {}
// DOM 元素引用
const personalFunnelChartCanvas = ref(null)
// Chart.js 数据
const funnelData = reactive({
labels: ['线索', '加微', '到课', '定金', '成交'],
data: [120, 90, 45, 18, 10],
})
// Chart.js: 创建或更新图表
const createOrUpdateChart = (chartId, canvasRef, config) => {
if (chartInstances[chartId]) {
chartInstances[chartId].destroy()
}
if (canvasRef.value) {
const ctx = canvasRef.value.getContext('2d')
chartInstances[chartId] = new Chart(ctx, config)
}
}
// Chart.js: 渲染销售漏斗图
const renderPersonalFunnelChart = () => {
const config = {
type: 'bar',
data: {
labels: funnelData.labels,
datasets: [{
label: '数量', data: funnelData.data,
backgroundColor: ['rgba(59, 130, 246, 0.8)', 'rgba(16, 185, 129, 0.8)', 'rgba(245, 158, 11, 0.8)', 'rgba(239, 68, 68, 0.8)'],
borderWidth: 1
}]
},
options: {
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
plugins: { legend: { display: false } },
scales: {
y: { grid: { display: false }, ticks: { color: '#64748b', font: { size: 11 } } },
x: { beginAtZero: true, grid: { color: 'rgba(148, 163, 184, 0.2)' }, ticks: { color: '#64748b', font: { size: 11 } } }
}
}
}
createOrUpdateChart('personalFunnel', personalFunnelChartCanvas, config)
}
// 生命周期钩子
onMounted(() => {
renderPersonalFunnelChart()
})
onBeforeUnmount(() => {
Object.values(chartInstances).forEach(chart => chart.destroy())
})
</script>
<style lang="scss" scoped>
// 颜色和变量定义
$slate-50: #f8fafc;
$slate-100: #f1f5f9;
$slate-200: #e2e8f0;
$slate-700: #334155;
$slate-800: #1e293b;
$slate-900: #303133;
$gray-400: #909399;
$gray-600: #606266;
$blue: #409eff;
$green: #67c23a;
$orange: #e6a23c;
$red: #f56c6c;
$white: #ffffff;
.group-ranking {
background: $white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
height: 23rem;
display: flex;
flex-direction: column;
}
.chart-container {
background: $white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
min-height: 380px;
display: flex;
flex-direction: column;
flex: 1;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 16px;
border-bottom: 1px solid #ebeef5;
h3 {
margin: 0;
color: $slate-900;
font-size: 18px;
font-weight: 600;
}
}
.chart-content {
padding-left: 20px;
padding-right: 20px;
padding-bottom: 20px;
flex-grow: 1;
position: relative;
canvas {
max-height: 280px;
}
}
// 响应式设计
@media (max-width: 768px) {
.group-ranking {
padding: 1rem;
height: auto;
min-height: 20rem;
}
.chart-container {
min-height: 300px;
}
.chart-header {
padding: 16px 16px 12px;
h3 {
font-size: 16px;
}
}
.chart-content {
padding-left: 16px;
padding-right: 16px;
padding-bottom: 16px;
}
}
@media (max-width: 480px) {
.group-ranking {
padding: 0.75rem;
height: auto;
min-height: 18rem;
}
.chart-container {
min-height: 250px;
}
.chart-header {
padding: 12px;
h3 {
font-size: 14px;
}
}
.chart-content {
padding: 12px;
}
}
</style>

View File

@@ -0,0 +1,200 @@
<template>
<div class="chart-container">
<div class="chart-header">
<h3>客户迫切解决的问题</h3>
</div>
<div class="chart-content">
<div class="problem-ranking">
<div v-for="(item, index) in sortedProblemData" :key="item.name" class="ranking-item" :class="getRankingClass(index)">
<div class="rank-number">
<span class="rank-badge" :class="getRankBadgeClass(index)">{{ index + 1 }}</span>
</div>
<div class="problem-info">
<div class="problem-name">{{ item.name }}</div>
<div class="problem-count">{{ item.value }}次咨询</div>
</div>
<div class="problem-percentage">
<span class="percentage">{{ getPercentage(item.value) }}%</span>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: getPercentage(item.value) + '%' }"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, computed } from 'vue';
// 问题排行榜数据
const problemData = reactive([
{ value: 180, name: '学习成绩提升' },
{ value: 150, name: '学习习惯培养' },
{ value: 120, name: '兴趣爱好发展' },
{ value: 100, name: '心理健康问题' },
{ value: 80, name: '升学规划' },
{ value: 70, name: '亲子关系改善' }
]);
// 计算属性
const sortedProblemData = computed(() => {
return [...problemData].sort((a, b) => b.value - a.value);
});
const totalProblemCount = computed(() => {
return problemData.reduce((sum, item) => sum + item.value, 0);
});
// 排行榜相关方法
const getPercentage = (value) => ((value / totalProblemCount.value) * 100).toFixed(1);
const getRankingClass = (index) => ({
'rank-first': index === 0,
'rank-second': index === 1,
'rank-third': index === 2,
'rank-other': index > 2
});
const getRankBadgeClass = (index) => ({
'badge-gold': index === 0,
'badge-silver': index === 1,
'badge-bronze': index === 2,
'badge-default': index > 2
});
</script>
<style lang="scss" scoped>
// 颜色变量
$slate-200: #e2e8f0;
$slate-900: #0f172a;
$white: #ffffff;
.chart-container {
background: $white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
height: 26rem !important;
max-height: 26rem;
// flex: 1;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 16px;
border-bottom: 1px solid #ebeef5;
h3 {
margin: 0;
color: $slate-900;
font-size: 18px;
font-weight: 600;
}
}
.chart-content {
padding-left: 20px;
padding-right: 20px;
padding-bottom: 20px;
flex-grow: 1;
position: relative;
overflow-y: auto;
}
.ranking-item {
display: flex;
align-items: center;
padding: 12px 0;
&:not(:last-child) {
border-bottom: 1px solid #f0f2f5;
}
}
.rank-number .rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-weight: bold;
font-size: 14px;
color: $white;
&.badge-gold {
background: linear-gradient(135deg, #ffd700, #ffb300);
}
&.badge-silver {
background: linear-gradient(135deg, #c0c0c0, #a8a8a8);
}
&.badge-bronze {
background: linear-gradient(135deg, #cd7f32, #b8860b);
}
&.badge-default {
background: linear-gradient(135deg, #6c757d, #495057);
}
}
.problem-info {
flex: 1;
margin: 0 16px;
}
.problem-name {
font-size: 15px;
font-weight: 500;
color: #212529;
margin-bottom: 4px;
}
.problem-count {
font-size: 13px;
color: #6c757d;
}
.problem-percentage {
min-width: 80px;
text-align: right;
}
.percentage {
font-size: 15px;
font-weight: bold;
color: #495057;
margin-bottom: 6px;
display: block;
}
.progress-bar {
width: 100%;
height: 6px;
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #007bff, #0056b3);
border-radius: 3px;
}
.rank-first .progress-fill {
background: linear-gradient(90deg, #ffd700, #ffb300);
}
.rank-second .progress-fill {
background: linear-gradient(90deg, #c0c0c0, #a8a8a8);
}
.rank-third .progress-fill {
background: linear-gradient(90deg, #cd7f32, #b8860b);
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<div class="stat-card kpi-card">
<div class="kpi-grid stats-grid-inner">
<div class="kpi-item stat-item">
<div class="stat-icon customer-rate">
<i class="el-icon-chat-dot-round"></i>
</div>
<div class="kpi-value">{{ customerCommunicationRate }}%</div>
<p>活跃客户沟通率</p>
</div>
<div class="kpi-item stat-item">
<div class="stat-icon response-time">
<i class="el-icon-timer"></i>
</div>
<div class="kpi-value">{{ averageResponseTime }}<span class="kpi-unit">分钟</span></div>
<p>平均应答时间</p>
</div>
<div class="kpi-item stat-item">
<div class="stat-icon timeout-rate">
<i class="el-icon-warning"></i>
</div>
<div class="kpi-value">{{ timeoutResponseRate }}%</div>
<p>超时应答率</p>
</div>
<div class="kpi-item stat-item">
<div class="stat-icon severe-timeout-rate">
<i class="el-icon-warning-outline"></i>
</div>
<div class="kpi-value">{{ severeTimeoutRate }}%</div>
<p>严重超时应答率</p>
</div>
<div class="kpi-item stat-item">
<div class="stat-icon form-rate">
<i class="el-icon-document"></i>
</div>
<div class="kpi-value">{{ formCompletionRate }}%</div>
<p>表格填写率</p>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
defineProps({
customerCommunicationRate: {
type: Number,
default: 0
},
averageResponseTime: {
type: Number,
default: 0
},
timeoutResponseRate: {
type: Number,
default: 0
},
severeTimeoutRate: {
type: Number,
default: 0
},
formCompletionRate: {
type: Number,
default: 0
}
});
</script>
<style scoped>
.stat-card {
background-color: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 15px;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
}
.kpi-item {
text-align: center;
}
.stat-icon {
font-size: 24px;
margin-bottom: 10px;
}
.kpi-value {
font-size: 24px;
font-weight: bold;
}
.kpi-unit {
font-size: 14px;
margin-left: 4px;
}
p {
font-size: 14px;
color: #666;
margin-top: 5px;
}
.customer-rate { color: #409EFF; }
.response-time { color: #67C23A; }
.timeout-rate { color: #E6A23C; }
.severe-timeout-rate { color: #F56C6C; }
.form-rate { color: #909399; }
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,255 @@
<template>
<div class="camp-management-board">
<header class="board-header">
<div>
<h1>营期节奏总览与调控</h1>
<p>点击任意中心行即可展开或收起详情调控面板</p>
</div>
<button class="save-button" @click="saveSettings">保存全部设置</button>
</header>
<div class="overview-panel">
<!-- 列表头部 -->
<div class="overview-header">
<span class="header-name">中心名称</span>
<span class="header-stage">当前营期阶段</span>
<span class="header-timeline">营期节奏分布</span>
<span class="header-days">总天数</span>
</div>
<!-- 中心列表 -->
<div class="center-list">
<template v-for="center in centersData" :key="center.id">
<!-- 1. 概览行 (可点击) -->
<div
class="center-summary-row"
@click="selectCenter(center.id)"
:class="{ 'selected': selectedCenterId === center.id }"
>
<span class="center-name">{{ center.name }}</span>
<span class="current-stage">{{ getCurrentStage(center) }}</span>
<!-- 可视化时间轴 -->
<div class="timeline-bar">
<div
v-for="stage in center.stages"
:key="stage.name"
class="timeline-segment"
:style="getStageStyle(center, stage)"
:title="`${stage.name}: ${stage.days} 天`"
>
<span class="stage-label" v-if="(stage.days / calculateTotalDays(center)) > 0.08">{{ stage.name }}</span>
</div>
</div>
<span class="total-days">{{ calculateTotalDays(center) }} </span>
</div>
<!-- 2. 详情调控面板 (条件渲染带过渡动画) -->
<transition name="slide-fade">
<div v-if="selectedCenterId === center.id" class="detail-control-panel">
<h4>正在调控: {{ center.name }}</h4>
<ul class="control-items-container">
<li v-for="stage in center.stages" :key="stage.name" class="control-item">
<span class="color-dot" :style="{ backgroundColor: stage.color }"></span>
<span class="stage-name-detail">{{ stage.name }}</span>
<input type="number" v-model.number="stage.days" min="0" class="days-input" />
<span></span>
</li>
</ul>
</div>
</transition>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
// 初始数据模型保持不变
const centersData = ref([
{ id: 1, name: '一中心', startDate: '2025-08-1', stages: [ { name: '接数据', days: 3, color: '#ffc107' }, { name: '课一', days: 1, color: '#0dcaf0' }, { name: '课二', days: 1, color: '#0d6efd' }, { name: '课三', days: 1, color: '#6f42c1' }, { name: '课四', days: 1, color: '#d63384' }] },
{ id: 2, name: '二中心', startDate: '2025-08-3', stages: [ { name: '接数据', days: 2, color: '#ffc107' }, { name: '课一', days: 1, color: '#0dcaf0' }, { name: '课二', days: 1, color: '#0d6efd' }, { name: '课三', days: 1, color: '#6f42c1' }, { name: '课四', days: 1, color: '#d63384' }] },
{ id: 3, name: '三中心', startDate: '2025-08-5', stages: [ { name: '接数据', days: 4, color: '#ffc107' }, { name: '课一', days: 1, color: '#0dcaf0' }, { name: '课二', days: 1, color: '#0d6efd' }, { name: '课三', days: 1, color: '#6f42c1' }, { name: '课四', days: 1, color: '#d63384' }] },
{ id: 4, name: '四中心', startDate: '2025-08-5', stages: [ { name: '接数据', days: 2, color: '#ffc107' }, { name: '课一', days: 1, color: '#0dcaf0' }, { name: '课二', days: 1, color: '#0d6efd' }, { name: '课三', days: 1, color: '#6f42c1' }, { name: '课四', days: 1, color: '#d63384' }] },
{ id: 5, name: '五中心', startDate: '2025-08-4', stages: [ { name: '接数据', days: 3, color: '#ffc107' }, { name: '课一', days: 1, color: '#0dcaf0' }, { name: '课二', days: 1, color: '#0d6efd' }, { name: '课三', days: 1, color: '#6f42c1' }, { name: '课四', days: 1, color: '#d63384' }] },
]);
// 新增状态用于追踪当前被选中的中心ID
const selectedCenterId = ref(null);
const getCurrentStage = (center) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const startDate = new Date(center.startDate);
startDate.setHours(0, 0, 0, 0);
if (today < startDate) {
return '未开始';
}
const diffTime = Math.abs(today - startDate);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; // 当天算第一天
let cumulativeDays = 0;
for (let i = 0; i < center.stages.length; i++) {
const stage = center.stages[i];
cumulativeDays += stage.days;
if (diffDays <= cumulativeDays) {
return `${stage.name}`;
}
}
return '已结束';
};
/**
* 选择一个中心进行调控
* 如果点击的是已选中的中心,则取消选择(收起面板)
* @param {number} centerId - 被点击的中心ID
*/
const selectCenter = (centerId) => {
if (selectedCenterId.value === centerId) {
selectedCenterId.value = null; // 取消选择
} else {
selectedCenterId.value = centerId; // 选择新的
}
};
// 以下函数与之前版本基本相同
const calculateTotalDays = (center) => {
return center.stages.reduce((sum, stage) => sum + (Number(stage.days) || 0), 0);
};
const getStageStyle = (center, stage) => {
const totalDays = calculateTotalDays(center);
const widthPercentage = totalDays > 0 ? (stage.days / totalDays) * 100 : 0;
return {
width: `${widthPercentage}%`,
backgroundColor: stage.color,
};
};
const saveSettings = () => {
console.log('正在保存设置...');
alert('所有中心的设置已保存!请在浏览器控制台查看最新数据。');
console.log('最新的营期数据:', JSON.parse(JSON.stringify(centersData.value)));
};
</script>
<style scoped>
/* 整体面板 */
.camp-management-board {
font-family: 'Helvetica Neue', Arial, sans-serif;
padding: 24px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
color: #333;
}
.board-header {
/* margin-bottom: 24px; */
display: flex;
justify-content: space-between;
align-items: center;
}
.board-header h1 { margin: 0; font-size: 20px; color: #2c3e50; }
.board-header p { color: #7f8c8d; margin: 8px 0 16px; }
.save-button { padding: 10px 20px; font-size: 14px; font-weight: bold; color: #fff; background-color: #4caf50; border: none; border-radius: 8px; cursor: pointer; transition: background-color 0.3s ease; }
.save-button:hover { background-color: #45a049; }
/* 总览面板 */
.overview-panel {
background-color: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden; /* 保证内部元素不超出圆角 */
}
.overview-header {
display: flex;
align-items: center;
padding: 12px 24px;
background-color: #f8f9fa;
color: #6c757d;
font-weight: 600;
font-size: 14px;
border-bottom: 1px solid #e9ecef;
}
.header-name { width: 15%; }
.header-stage { width: 15%; text-align: center; }
.header-timeline { flex: 1; text-align: center; }
.header-days { width: 10%; text-align: right; }
/* 中心概览行 */
.center-summary-row {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #e9ecef;
cursor: pointer;
transition: background-color 0.3s ease;
}
.center-summary-row:last-child {
border-bottom: none;
}
.center-summary-row:hover {
background-color: #f1f3f5;
}
.center-summary-row.selected {
background-color: #e7f5ff; /* 选中时的背景色 */
border-left: 4px solid #1c7ed6; /* 选中时左侧的强调线 */
padding-left: 20px;
}
.center-name { width: 15%; font-size: 18px; font-weight: 500; color: #34495e; }
.current-stage { width: 15%; text-align: center; font-size: 16px; font-weight: 500; color: #28a745; }
.timeline-bar { flex: 1; display: flex; height: 32px; border-radius: 6px; background-color: #e9ecef; overflow: hidden; margin: 0 20px; }
.timeline-segment { height: 100%; display: flex; align-items: center; justify-content: center; color: white; font-size: 12px; font-weight: bold; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); }
.total-days { width: 10%; text-align: right; font-size: 18px; font-weight: bold; color: #e67e22; }
/* 详情调控面板 */
.detail-control-panel {
/* padding: 20px 24px 12px 24px; */
background-color: #fafafa;
border-bottom: 1px solid #e9ecef
}
.detail-control-panel h4 { margin-top: 0; margin-bottom: 16px; font-size: 16px; color: #1c7ed6; }
.detail-control-panel .control-items-container {
list-style: none;
padding: 0;
margin: 0;
display: flex;
/* flex-wrap: wrap; */
}
.control-item {
display: flex;
align-items: center;
margin-bottom: 10px;
margin-right: 15px;
}
.color-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.stage-name-detail {width: 50px; font-size: 16px; flex-shrink: 0; }
.days-input { width: 70px; padding: 8px; border: 1px solid #ccc; border-radius: 6px; text-align: center; font-size: 16px; margin-right: 8px; }
.days-input:focus { outline: none; border-color: #0d6efd; box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.25); }
/* 过渡动画 */
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateY(-10px);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="dashboard-card">
<div class="card-header">
<h3>沟通总数据</h3>
<span class="metric-period">本周</span>
</div>
<div class="communication-cards">
<div class="comm-card">
<div class="card-icon">📞</div>
<div class="card-content">
<div class="card-label">总通话时长</div>
<div class="card-value">{{ communicationData.totalDuration }}小时</div>
</div>
</div>
<div class="comm-card">
<div class="card-icon"></div>
<div class="card-content">
<div class="card-label">有效沟通率</div>
<div class="card-value">{{ communicationData.effectiveRate }}%</div>
</div>
</div>
<div class="comm-card">
<div class="card-icon"></div>
<div class="card-content">
<div class="card-label">首次响应时长</div>
<div class="card-value">{{ communicationData.firstResponseTime }}</div>
</div>
</div>
<div class="comm-card">
<div class="card-icon">📊</div>
<div class="card-content">
<div class="card-label">接通率</div>
<div class="card-value">{{ communicationData.connectionRate }}%</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
defineProps({
communicationData: Object
});
</script>
<style scoped>
.dashboard-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 20px;
display: flex;
flex-direction: column;
flex: 1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-header h3 {
margin: 0;
font-size: 18px;
}
.metric-period {
font-size: 14px;
color: #666;
}
.communication-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
}
.comm-card {
display: flex;
align-items: center;
background-color: #f9f9f9;
padding: 15px;
border-radius: 8px;
}
.card-icon {
font-size: 24px;
margin-right: 15px;
}
.card-label {
font-size: 14px;
color: #666;
}
.card-value {
font-size: 18px;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div class="dashboard-card">
<div class="card-header">
<h3>客户画像</h3>
</div>
<div class="customer-profile">
<div class="profile-section">
<h4>家长类型分布</h4>
<div class="parent-types">
<div v-for="type in parentTypes" :key="type.name" class="parent-type-item">
<div class="type-info">
<span class="type-name">{{ type.name }}</span>
<span class="type-percentage">{{ type.percentage }}%</span>
</div>
<div class="type-bar">
<div class="type-fill" :style="{ width: type.percentage + '%', backgroundColor: type.color }"></div>
</div>
</div>
</div>
</div>
<div class="profile-section" style="margin-bottom: 25px;">
<h4>热门问题排行</h4>
<div class="hot-questions">
<div v-for="(question, index) in hotQuestions.slice(0, 3)" :key="question.id" class="question-item">
<div class="question-rank">{{ index + 1 }}</div>
<div class="question-content">
<div class="question-text">{{ question.text }}</div>
<div class="question-count">{{ question.count }}次咨询</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
defineProps({
parentTypes: Array,
hotQuestions: Array
});
</script>
<style scoped>
.dashboard-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 20px;
display: flex;
flex-direction: column;
max-height: 300px;
overflow-y: auto;
/* flex: 1; */
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-header h3 {
margin: 0;
font-size: 18px;
}
.customer-profile .profile-section h4 {
font-size: 16px;
margin-bottom: 15px;
}
.parent-type-item {
margin-bottom: 10px;
}
.type-info {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 14px;
}
.type-bar {
background-color: #f0f0f0;
border-radius: 4px;
height: 8px;
}
.type-fill {
height: 100%;
border-radius: 4px;
}
.hot-questions .question-item {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.question-rank {
font-size: 16px;
font-weight: bold;
width: 30px;
text-align: center;
}
.question-text {
font-size: 14px;
}
.question-count {
font-size: 12px;
color: #666;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<div class="dashboard-card detail-section">
<div class="card-header">
<h3>数据详情</h3>
</div>
<div class="detail-content">
<div v-if="!selectedPerson" class="no-selection">
<div class="empty-icon">📊</div>
<p>请点击左侧表格中的人员查看详细数据</p>
</div>
<div v-else class="person-detail">
<div class="detail-header">
<div class="detail-avatar">{{ selectedPerson.name.charAt(0) }}</div>
<div class="detail-info">
<h4>{{ selectedPerson.name }}</h4>
<p>{{ selectedPerson.position }} - {{ selectedPerson.department }}</p>
</div>
</div>
<div class="detail-placeholder">
<p>详细数据面板</p>
<p class="placeholder-text">此处将显示选中人员的详细数据分析包括业绩趋势客户分析等信息</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
defineProps({
selectedPerson: Object
});
</script>
<style scoped>
.dashboard-card.detail-section {
grid-column: 3 / 4;
}
.detail-content .no-selection {
text-align: center;
padding: 40px 20px;
color: #999;
}
.no-selection .empty-icon {
font-size: 48px;
margin-bottom: 20px;
}
.person-detail .detail-header {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.detail-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: #2196F3;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
margin-right: 20px;
}
.detail-info h4 {
margin: 0;
font-size: 20px;
}
.detail-info p {
margin: 0;
color: #666;
}
.detail-placeholder {
text-align: center;
padding: 20px;
background-color: #f9f9f9;
border-radius: 8px;
}
.placeholder-text {
font-size: 14px;
color: #999;
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<div class="dashboard-card detail-section">
<div class="card-header">
<h3>数据详情</h3>
</div>
<div class="detail-content">
<div v-if="!selectedPerson" class="no-selection">
<div class="empty-icon">📊</div>
<p>请点击左侧表格中的人员查看详细数据</p>
</div>
<div v-else class="person-detail">
<div class="detail-header">
<div class="detail-avatar">{{ selectedPerson.name.charAt(0) }}</div>
<div class="detail-info">
<h4>{{ selectedPerson.name }}</h4>
<p>{{ selectedPerson.position }} - {{ selectedPerson.department }}</p>
</div>
</div>
<div class="detail-placeholder">
<p>详细数据面板</p>
<p class="placeholder-text">此处将显示选中人员的详细数据分析包括业绩趋势客户分析等信息</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
selectedPerson: { type: Object, default: null }
});
</script>
<style scoped>
.detail-section { height: 600px; }
.detail-content { height: calc(100% - 60px); overflow-y: auto; }
.no-selection { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #64748b; text-align: center; }
.empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
.person-detail { padding: 20px; }
.detail-header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #e2e8f0; }
.detail-avatar { width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 600; }
.detail-info h4 { margin: 0 0 4px 0; font-size: 20px; }
.detail-info p { margin: 0; color: #64748b; font-size: 14px; }
.detail-placeholder { text-align: center; padding: 40px 20px; color: #64748b; }
.detail-placeholder p:first-child { font-size: 18px; font-weight: 600; margin-bottom: 12px; }
.placeholder-text { font-size: 14px; opacity: 0.8; }
</style>

View File

@@ -0,0 +1,233 @@
<template>
<div class="dashboard-card table-section">
<div class="card-header">
<h3>详细数据表格</h3>
</div>
<div class="data-table-container">
<!-- 筛选器 -->
<div class="table-filters">
<div class="filter-group">
<label>部门:</label>
<select v-model="filters.department">
<option value="">全部部门</option>
<option value="销售一部">销售一部</option>
<option value="销售二部">销售二部</option>
<option value="销售三部">销售三部</option>
</select>
</div>
<div class="filter-group">
<label>职位:</label>
<select v-model="filters.position">
<option value="">全部职位</option>
<option value="销售经理">销售经理</option>
<option value="销售专员">销售专员</option>
<option value="销售助理">销售助理</option>
</select>
</div>
<div class="filter-group">
<label>时间范围:</label>
<select v-model="filters.timeRange">
<option value="today">今日</option>
<option value="week">本周</option>
<option value="month">本月</option>
<option value="quarter">本季度</option>
</select>
</div>
<div class="filter-group">
<label>成交状态:</label>
<select v-model="filters.dealStatus">
<option value="">全部状态</option>
<option value="已成交">已成交</option>
<option value="跟进中">跟进中</option>
<option value="已失效">已失效</option>
</select>
</div>
</div>
<!-- 数据表格 -->
<div class="data-table">
<table>
<thead>
<tr>
<th>人员</th>
<th @click="$emit('sort-by', 'dealRate')" class="sortable">
成交率
<span class="sort-icon" :class="{ active: sortField === 'dealRate' }">
{{ sortOrder === 'desc' ? '↓' : '↑' }}
</span>
</th>
<th @click="$emit('sort-by', 'callDuration')" class="sortable">
通话时长
<span class="sort-icon" :class="{ active: sortField === 'callDuration' }">
{{ sortOrder === 'desc' ? '↓' : '↑' }}
</span>
</th>
<th>通话次数</th>
<th>成交金额</th>
<th>部门</th>
</tr>
</thead>
<tbody>
<tr v-for="person in filteredTableData" :key="person.id" @click="$emit('select-person', person)" :class="{ selected: selectedPerson && selectedPerson.id === person.id }">
<td>
<div class="person-info">
<div class="person-avatar">{{ person.name.charAt(0) }}</div>
<div>
<div class="person-name">{{ person.name }}</div>
<div class="person-position">{{ person.position }}</div>
</div>
</div>
</td>
<td>
<div class="deal-rate">
<span class="rate-value" :class="getRateClass(person.dealRate)">{{ person.dealRate }}%</span>
<div class="rate-bar">
<div class="rate-fill" :style="{ width: person.dealRate + '%', backgroundColor: getRateColor(person.dealRate) }"></div>
</div>
</div>
</td>
<td>{{ formatDuration(person.callDuration) }}</td>
<td>{{ person.callCount }}</td>
<td>¥{{ person.dealAmount.toLocaleString() }}</td>
<td>{{ person.department }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, reactive } from 'vue';
defineProps({
filteredTableData: Array,
selectedPerson: Object,
sortField: String,
sortOrder: String,
getRateClass: Function,
getRateColor: Function,
formatDuration: Function
});
defineEmits(['sort-by', 'select-person']);
const filters = reactive({
department: '',
position: '',
timeRange: 'month',
dealStatus: ''
});
</script>
<style scoped>
.dashboard-card.table-section {
grid-column: 1 / 3;
}
.data-table-container {
margin-top: 20px;
}
.table-filters {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-group label {
font-size: 14px;
}
.filter-group select {
padding: 6px 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
.data-table table {
width: 100%;
border-collapse: collapse;
}
.data-table th, .data-table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #eee;
}
.data-table th.sortable {
cursor: pointer;
}
.sort-icon {
opacity: 0.5;
}
.sort-icon.active {
opacity: 1;
}
.person-info {
display: flex;
align-items: center;
}
.person-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #4CAF50;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 10px;
}
.person-name {
font-weight: bold;
}
.person-position {
font-size: 12px;
color: #666;
}
.deal-rate .rate-value {
font-weight: bold;
}
.deal-rate .rate-bar {
height: 6px;
background-color: #f0f0f0;
border-radius: 3px;
margin-top: 5px;
}
.deal-rate .rate-fill {
height: 100%;
border-radius: 3px;
}
tbody tr {
cursor: pointer;
transition: background-color 0.2s;
}
tbody tr:hover {
background-color: #f5f5f5;
}
tbody tr.selected {
background-color: #e8f5e9;
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div class="dashboard-card table-section">
<div class="card-header">
<h3>详细数据表格</h3>
</div>
<div class="data-table-container">
<!-- 筛选器 -->
<div class="table-filters">
<div class="filter-group"><label>中心:</label><select v-model="filters.department"><option value="">全部中心</option><option>销售一部</option><option>销售二部</option><option>销售三部</option></select></div>
<div class="filter-group"><label>高级经理:</label><select v-model="filters.position"><option value="">全部高级经理</option><option>经理1</option><option>经理2</option><option>经理3</option></select></div>
<div class="filter-group"><label>经理:</label><select v-model="filters.timeRange"><option value="today">经理1</option><option value="week">经理2</option><option value="month">经理3</option></select></div>
<!-- <div class="filter-group"><label>成交状态:</label><select v-model="filters.dealStatus"><option value="">全部</option><option>已成交</option><option>跟进中</option><option>已失效</option></select></div> -->
</div>
<!-- 数据表格 -->
<div class="data-table">
<table>
<thead>
<tr>
<th>人员</th>
<th @click="sortBy('dealRate')" class="sortable">成交率 <span class="sort-icon" :class="{ active: sortField === 'dealRate' }">{{ sortOrder === 'desc' ? '↓' : '↑' }}</span></th>
<th @click="sortBy('callDuration')" class="sortable">通话时长 <span class="sort-icon" :class="{ active: sortField === 'callDuration' }">{{ sortOrder === 'desc' ? '↓' : '↑' }}</span></th>
<th>通话次数</th>
<th>成交金额</th>
<th>部门</th>
</tr>
</thead>
<tbody>
<tr v-for="person in filteredTableData" :key="person.id" @click="$emit('update:selectedPerson', person)" :class="{ selected: selectedPerson && selectedPerson.id === person.id }">
<td>
<div class="person-info">
<div class="person-avatar">{{ person.name.charAt(0) }}</div>
<div>
<div class="person-name">{{ person.name }}</div>
<div class="person-position">{{ person.position }}</div>
</div>
</div>
</td>
<td>
<div class="deal-rate">
<span class="rate-value" :class="getRateClass(person.dealRate)">{{ person.dealRate }}%</span>
<div class="rate-bar"><div class="rate-fill" :style="{ width: person.dealRate + '%', backgroundColor: getRateColor(person.dealRate) }"></div></div>
</div>
</td>
<td>{{ formatDuration(person.callDuration) }}</td>
<td>{{ person.callCount }}</td>
<td>¥{{ person.dealAmount.toLocaleString() }}</td>
<td>{{ person.department }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
tableData: { type: Array, required: true },
selectedPerson: { type: Object, default: null }
});
defineEmits(['update:selectedPerson']);
const filters = ref({ department: '', position: '', timeRange: 'month', dealStatus: '' });
const sortField = ref('dealRate');
const sortOrder = ref('desc');
const filteredTableData = computed(() => {
let filtered = props.tableData;
if (filters.value.department) filtered = filtered.filter(item => item.department === filters.value.department);
if (filters.value.position) filtered = filtered.filter(item => item.position === filters.value.position);
return filtered.sort((a, b) => {
const aValue = a[sortField.value], bValue = b[sortField.value];
return sortOrder.value === 'desc' ? bValue - aValue : aValue - bValue;
});
});
const sortBy = (field) => {
if (sortField.value === field) sortOrder.value = sortOrder.value === 'desc' ? 'asc' : 'desc';
else { sortField.value = field; sortOrder.value = 'desc'; }
};
const getRateClass = (rate) => {
if (rate >= 80) return 'high'; if (rate >= 60) return 'medium'; return 'low';
};
const getRateColor = (rate) => {
if (rate >= 80) return '#4CAF50'; if (rate >= 60) return '#FF9800'; return '#f44336';
};
const formatDuration = (minutes) => {
const h = Math.floor(minutes / 60), m = minutes % 60;
return h > 0 ? `${h}h${m}m` : `${m}m`;
};
</script>
<style scoped>
.table-section { height: 600px; }
.data-table-container { height: calc(100% - 60px); overflow-y: auto; padding: 24px; }
.table-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; padding: 16px; background: #f8fafc; border-radius: 8px; }
.filter-group { display: flex; flex-direction: column; gap: 4px; }
.filter-group label { font-size: 12px; font-weight: 600; color: #4a5568; }
.filter-group select { padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 6px; }
.data-table { overflow-x: auto; border-radius: 8px; border: 1px solid #e2e8f0; }
table { width: 100%; border-collapse: collapse; }
th { background: #f7fafc; padding: 12px 16px; text-align: left; font-weight: 600; color: #4a5568; border-bottom: 1px solid #e2e8f0; font-size: 12px; }
th.sortable { cursor: pointer; }
.sort-icon { margin-left: 4px; opacity: 0.5; }
.sort-icon.active { opacity: 1; color: #4299e1; }
td { padding: 16px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
tr { cursor: pointer; transition: background-color 0.2s ease; }
tr:hover { background-color: #f8fafc; }
tr.selected { background-color: #e0f2fe; border-left: 4px solid #0ea5e9; }
.person-info { display: flex; align-items: center; gap: 12px; }
.person-avatar { width: 40px; height: 40px; border-radius: 50%; background: #4299e1; color: white; display: flex; align-items: center; justify-content: center; font-weight: 600; }
.person-name { font-weight: 600; }
.person-position { font-size: 12px; color: #718096; }
.deal-rate { min-width: 80px; }
.rate-value { font-weight: 600; }
.rate-value.high { color: #4CAF50; }
.rate-value.medium { color: #FF9800; }
.rate-value.low { color: #f44336; }
.rate-bar { height: 4px; background: #edf2f7; border-radius: 2px; overflow: hidden; }
.rate-fill { height: 100%; }
</style>

View File

@@ -0,0 +1,357 @@
<template>
<div class="dashboard-card">
<div class="card-header">
<h3>转化对比图</h3>
<div class="time-selector">
<select v-model="selectedTimeRange" @change="handleTimeRangeChange" class="time-select">
<option value="period">本期 vs 上期</option>
<option value="month">本月 vs 上月</option>
</select>
</div>
</div>
<div class="bar-chart">
<div class="chart-legend">
<div class="legend-item">
<span class="legend-color current"></span>
<span class="legend-text">{{ currentPeriodLabel }}</span>
</div>
<div class="legend-item">
<span class="legend-color previous"></span>
<span class="legend-text">{{ previousPeriodLabel }}</span>
</div>
</div>
<div class="chart-container">
<div v-for="(stage, index) in chartData" :key="stage.name" class="chart-stage">
<div class="bars-container">
<div class="bar-group">
<div
class="bar current-bar"
:style="{ height: getBarHeight(stage.current, maxValue) + '%' }"
:title="`${currentPeriodLabel}: ${stage.current}`"
>
<span class="bar-value">{{ stage.current }}</span>
</div>
<div
class="bar previous-bar"
:style="{ height: getBarHeight(stage.previous, maxValue) + '%' }"
:title="`${previousPeriodLabel}: ${stage.previous}`"
>
<span class="bar-value">{{ stage.previous }}</span>
</div>
</div>
<div class="change-indicator">
<span
:class="['change-text', getChangeClass(stage.current, stage.previous)]"
>
{{ getChangeText(stage.current, stage.previous) }}
</span>
</div>
</div>
<div class="stage-name">{{ stage.name }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, defineProps, defineEmits } from 'vue';
const props = defineProps({
funnelData: Array,
comparisonData: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits(['time-range-change']);
const selectedTimeRange = ref('period');
// 计算属性:当前和上一期的标签
const currentPeriodLabel = computed(() => {
return selectedTimeRange.value === 'period' ? '本期' : '本月';
});
const previousPeriodLabel = computed(() => {
return selectedTimeRange.value === 'period' ? '上期' : '上月';
});
// 计算属性:图表数据
const chartData = computed(() => {
if (!props.funnelData || !Array.isArray(props.funnelData)) {
return [];
}
return props.funnelData.map(stage => {
const comparisonStage = props.comparisonData[selectedTimeRange.value]?.find(
item => item.name === stage.name
);
return {
name: stage.name,
current: stage.count || 0,
previous: comparisonStage?.count || 0
};
});
});
// 计算属性:最大值(用于计算柱状图高度)
const maxValue = computed(() => {
if (!chartData.value.length) return 100;
const allValues = chartData.value.flatMap(stage => [stage.current, stage.previous]);
return Math.max(...allValues, 1);
});
// 方法:计算柱状图高度百分比
const getBarHeight = (value, max) => {
if (!value || !max) return 0;
return Math.max((value / max) * 100, 2); // 最小高度2%
};
// 方法:获取变化文本
const getChangeText = (current, previous) => {
if (!previous) return current > 0 ? '+100%' : '0%';
const change = ((current - previous) / previous) * 100;
const sign = change >= 0 ? '+' : '';
return `${sign}${change.toFixed(1)}%`;
};
// 方法:获取变化样式类
const getChangeClass = (current, previous) => {
if (!previous) return current > 0 ? 'positive' : 'neutral';
const change = current - previous;
if (change > 0) return 'positive';
if (change < 0) return 'negative';
return 'neutral';
};
// 方法:处理时间范围变化
const handleTimeRangeChange = () => {
emit('time-range-change', selectedTimeRange.value);
};
</script>
<style scoped>
.dashboard-card {
background-color: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
padding: 24px;
display: flex;
flex-direction: column;
flex: 1;
height: 400px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
}
.time-selector {
display: flex;
align-items: center;
}
.time-select {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background-color: #fff;
font-size: 14px;
color: #374151;
cursor: pointer;
transition: all 0.2s ease;
}
.time-select:hover {
border-color: #3b82f6;
}
.time-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.bar-chart {
flex: 1;
display: flex;
flex-direction: column;
}
.chart-legend {
display: flex;
justify-content: center;
margin: 20px 0;
gap: 24px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 4px;
}
.legend-color.current {
background-color: #3b82f6;
}
.legend-color.previous {
background-color: #e5e7eb;
}
.legend-text {
font-size: 14px;
color: #6b7280;
font-weight: 500;
}
.chart-container {
flex: 1;
display: flex;
justify-content: center;
/* align-items: flex-end; */
padding: 20px 0;
min-height: 250px;
}
.chart-stage {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
max-width: 80px;
}
.stage-name {
font-size: 12px;
color: #6b7280;
margin-bottom: 12px;
text-align: center;
font-weight: 500;
}
.bars-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 200px;
}
.bar-group {
display: flex;
justify-content: center;
align-items: flex-end;
gap: 4px;
height: 160px;
width: 100%;
}
.bar {
width: 24px;
min-height: 4px;
border-radius: 4px 4px 0 0;
position: relative;
transition: all 0.3s ease;
cursor: pointer;
display: flex;
align-items: flex-end;
justify-content: center;
}
.bar:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.current-bar {
background-color: #3b82f6;
}
.previous-bar {
background-color: #e5e7eb;
}
.bar-value {
position: absolute;
top: -20px;
font-size: 11px;
font-weight: 600;
color: #374151;
white-space: nowrap;
}
.change-indicator {
margin-top: 8px;
text-align: center;
}
.change-text {
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
}
.change-text.positive {
color: #059669;
background-color: #d1fae5;
}
.change-text.negative {
color: #dc2626;
background-color: #fee2e2;
}
.change-text.neutral {
color: #6b7280;
background-color: #f3f4f6;
}
/* 响应式设计 */
@media (max-width: 768px) {
.dashboard-card {
padding: 16px;
}
.card-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.chart-container {
flex-wrap: wrap;
gap: 16px;
}
.chart-stage {
min-width: 80px;
}
.bar {
width: 20px;
}
}
</style>

View File

@@ -0,0 +1,349 @@
<template>
<div class="overview-container">
<!-- 加载状态 -->
<div v-if="isLoading" class="state-container">
<div class="spinner"></div>
<p>正在加载数据...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="state-container">
<p class="error-text">数据加载失败{{ error }}</p>
<button @click="fetchData" class="retry-button">重试</button>
</div>
<!-- 成功状态显示数据网格 -->
<div v-else class="kpi-grid">
<!-- 1. 主卡片中心总业绩 -->
<div class="kpi-card primary">
<div class="card-header">
<span class="card-label">总成交单数</span>
<span class="card-trend" :class="getTrendClass(kpiData.totalSales.trend)">
{{ formatTrend(kpiData.totalSales.trend) }} vs 上期
</span>
</div>
<div class="card-body">
<span class="card-value">{{ formatNumber(kpiData.totalSales.value) }}</span>
<span class="card-unit">单数</span>
</div>
<div class="card-footer">
月目标完成率{{ kpiData.totalSales.targetCompletion }}%
</div>
</div>
<!-- 2. 活跃组数 -->
<div class="kpi-card">
<div class="card-header">
<span class="card-label">定金转化率</span>
<span class="card-trend" :class="getTrendClass(kpiData.activeTeams.trend)">
{{ formatTrend(kpiData.activeTeams.trend, true) }} vs 上期
</span>
</div>
<div class="card-body">
<span class="card-value">{{ kpiData.activeTeams.value }}</span>
<span class="card-unit"></span>
</div>
<div class="card-footer">
总人数{{ kpiData.activeTeams.totalMembers }}
</div>
</div>
<!-- 3. 总通话次数 -->
<div class="kpi-card">
<div class="card-header">
<span class="card-label">总通话次数</span>
<span class="card-trend" :class="getTrendClass(kpiData.totalCalls.trend)">
{{ formatTrend(kpiData.totalCalls.trend) }} vs 上期
</span>
</div>
<div class="card-body">
<span class="card-value">{{ formatNumber(kpiData.totalCalls.value) }}</span>
<span class="card-unit"></span>
</div>
<div class="card-footer">
有效通话{{ formatNumber(kpiData.totalCalls.effectiveCalls) }}
</div>
</div>
<!-- 4. 新增客户 -->
<div class="kpi-card">
<div class="card-header">
<span class="card-label">新增客户</span>
<span class="card-trend" :class="getTrendClass(kpiData.newCustomers.trend)">
{{ formatTrend(kpiData.newCustomers.trend) }} vs 上期
</span>
</div>
<div class="card-body">
<span class="card-value">{{ formatNumber(kpiData.newCustomers.value) }}</span>
<span class="card-unit"></span>
</div>
<div class="card-footer">
意向客户{{ formatNumber(kpiData.newCustomers.interestedCustomers) }}
</div>
</div>
<!-- 5. 中心转化率 -->
<div class="kpi-card">
<div class="card-header">
<span class="card-label">平均转化率</span>
<span class="card-trend" :class="getTrendClass(kpiData.conversionRate.trend)">
{{ formatTrend(kpiData.conversionRate.trend, true) }} vs 上期
</span>
</div>
<div class="card-body">
<span class="card-value">{{ kpiData.conversionRate.value }}</span>
<span class="card-unit">%</span>
</div>
<div class="card-footer">
行业平均{{ kpiData.conversionRate.industryAvg }}%
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
// 1. 定义内部响应式状态
const kpiData = ref({
// 定义一个骨架结构,防止模板在初始渲染时出错
totalSales: {},
activeTeams: {},
conversionRate: {},
totalCalls: {},
newCustomers: {},
});
const isLoading = ref(true); // 加载状态默认为true
const error = ref(null); // 错误状态默认为null
// 2. 模拟从API获取数据的函数
async function fetchData() {
isLoading.value = true;
error.value = null;
try {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 1500));
// 模拟成功获取的数据。在实际应用中,这里会是 fetch() 或 axios.get()
const responseData = {
totalSales: { value: 552000, trend: 12, targetCompletion: 56 },
activeTeams: { value: 5, total: 5, totalMembers: 40 },
conversionRate: { value: 5.2, trend: 0.3, industryAvg: 4.8 },
totalCalls: { value: 1247, trend: -8, effectiveCalls: 892 }, // 示例:负向趋势
newCustomers: { value: 117, trend: 15, interestedCustomers: 89 },
};
// 更新组件的内部数据
kpiData.value = responseData;
} catch (e) {
// 如果发生错误,更新错误状态
error.value = e.message || '未知错误';
} finally {
// 无论成功或失败,最后都设置加载完成
isLoading.value = false;
}
}
// 3. 在组件挂载后调用数据获取函数
onMounted(() => {
fetchData();
});
// --- 以下是辅助函数,保持不变 ---
// 格式化数字,添加千位分隔符
function formatNumber(num) {
if (num == null) return '0';
return num.toLocaleString();
}
// 根据趋势值返回 'positive' 或 'negative' 类
function getTrendClass(trend) {
if (trend > 0) return 'positive';
if (trend < 0) return 'negative';
return 'neutral';
}
// 格式化趋势百分比,自动添加 '+'
function formatTrend(trend, isPercentagePoint = false) {
if (trend == null) return '';
const sign = trend > 0 ? '+' : '';
const unit = isPercentagePoint ? '' : '%';
return `${sign}${trend}${unit}`;
}
</script>
<style scoped>
/* 可将此背景色应用到页面body或父容器 */
.overview-container {
background-color: #ffffff;
padding: 0.5rem;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
max-height: 350px; /* 给容器一个最小高度以容纳加载/错误状态 */
border-radius: 8px;
}
/* --- 新增:加载和错误状态的样式 --- */
.state-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 200px;
color: #86909c;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #3a7afe;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-text {
color: #f53f3f;
margin-bottom: 16px;
}
.retry-button {
background-color: #3a7afe;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.retry-button:hover {
background-color: #2f68ee;
}
/* --- 以下是卡片网格样式,保持不变 --- */
.kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.kpi-card {
background-color: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 8px;
transition: all 0.2s ease-in-out;
}
.kpi-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
}
.kpi-card.primary {
grid-column: 1 / 3;
background: linear-gradient(135deg, #3a7afe, #2f68ee);
color: #ffffff;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-label {
font-size: 14px;
color: #86909c;
}
.primary .card-label {
color: rgba(255, 255, 255, 0.8);
}
.card-trend {
font-size: 14px;
font-weight: 500;
}
.card-trend.positive { color: #00b42a; }
.card-trend.negative { color: #f53f3f; }
.primary .card-trend { color: #ffffff; }
.card-secondary-info {
font-size: 14px;
color: #4e5969;
font-weight: 500;
}
.card-body {
display: flex;
align-items: baseline;
margin-top: 4px;
margin-bottom: 4px;
}
.card-value {
font-size: 36px;
font-weight: 700;
color: #1d2129;
line-height: 1.2;
}
.primary .card-value {
font-size: 48px;
color: #ffffff;
}
.card-unit {
font-size: 16px;
font-weight: 500;
color: #1d2129;
margin-left: 8px;
}
.primary .card-unit {
font-size: 20px;
color: #ffffff;
}
.card-footer {
font-size: 12px;
color: #86909c;
}
.primary .card-footer {
color: rgba(255, 255, 255, 0.8);
}
@media (max-width: 1200px) {
.kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
.kpi-card.primary {
grid-column: 1 / 3;
}
}
@media (max-width: 768px) {
.kpi-grid {
grid-template-columns: 1fr;
}
.kpi-card.primary {
grid-column: auto;
}
.primary .card-value {
font-size: 40px;
}
}
</style>

View File

@@ -0,0 +1,631 @@
<template>
<div class="dashboard-card">
<div class="card-header">
<h3>{{ rankingType === 'red' ? '销售月度业绩排行榜(红榜)' : '销售月度业绩排行榜(黑榜)' }}</h3>
<div class="ranking-toggle">
<button
:class="['toggle-btn', { active: rankingType === 'red' }]"
@click="rankingType = 'red'"
>
红榜
</button>
<button
:class="['toggle-btn', { active: rankingType === 'black' }]"
@click="rankingType = 'black'"
>
黑榜
</button>
</div>
</div>
<div class="ranking-list">
<div v-for="(item, index) in displayData" :key="item.id" class="ranking-item" :class="{ 'top-three': index < 3, 'black-list': rankingType === 'black' }">
<div class="rank-number" :class="getRankClass(index)">
<span v-if="index === 0" class="crown">👑</span>
<span v-else-if="index === 1" class="medal">🥈</span>
<span v-else-if="index === 2" class="medal">🥉</span>
<span v-else>{{ index + 1 }}</span>
</div>
<div class="employee-info">
<div class="employee-name">{{ item.name }}</div>
<div class="employee-dept">{{ item.department }}</div>
</div>
<div class="employee-stats">
<span class="deals">成交: {{ item.deals }}</span>
<span class="conversion">转化率: {{ item.conversionRate }}%</span>
</div>
<div class="performance-section">
<div class="performance-value">
¥{{ formatNumber(item.performance) }}
</div>
</div>
</div>
</div>
<div v-if="displayData.length === 0" class="empty-state">
<p>暂无数据</p>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, ref, computed } from 'vue';
const props = defineProps({
rankingData: {
type: Array,
default: () => []
},
formatNumber: {
type: Function,
default: (num) => {
if (num >= 10000) {
return (num / 10000).toFixed(1) + '万';
}
return num.toLocaleString();
}
},
getRankClass: {
type: Function,
default: (index) => {
if (index === 0) return 'gold';
if (index === 1) return 'silver';
if (index === 2) return 'bronze';
return '';
}
}
});
const emit = defineEmits(['period-change']);
const rankingPeriod = ref('month');
const rankingType = ref('red'); // 'red' 为红榜,'black' 为黑榜
// 模拟数据
const mockData = [
{
id: 1,
name: '张明',
department: '华东区',
performance: 156800,
deals: 28,
conversionRate: 85.2,
trend: 'up',
growth: 12.5,
avatar: '/default-avatar.svg'
},
{
id: 2,
name: '李娜',
department: '华南区',
performance: 142300,
deals: 25,
conversionRate: 78.9,
trend: 'up',
growth: 8.3,
avatar: '/default-avatar.svg'
},
{
id: 3,
name: '王强',
department: '华北区',
performance: 138900,
deals: 23,
conversionRate: 82.1,
trend: 'down',
growth: -2.1,
avatar: '/default-avatar.svg'
},
{
id: 4,
name: '赵丽',
department: '西南区',
performance: 125600,
deals: 21,
conversionRate: 75.4,
trend: 'up',
growth: 15.2,
avatar: '/default-avatar.svg'
},
{
id: 5,
name: '陈伟',
department: '华中区',
performance: 118700,
deals: 19,
conversionRate: 71.8,
trend: 'stable',
growth: 0.5,
avatar: '/default-avatar.svg'
},
{
id: 6,
name: '刘芳',
department: '东北区',
performance: 112400,
deals: 18,
conversionRate: 69.3,
trend: 'up',
growth: 6.7,
avatar: '/default-avatar.svg'
},
{
id: 7,
name: '杨磊',
department: '西北区',
performance: 98500,
deals: 16,
conversionRate: 65.2,
trend: 'down',
growth: -5.3,
avatar: '/default-avatar.svg'
},
{
id: 8,
name: '周敏',
department: '华东区',
performance: 89300,
deals: 14,
conversionRate: 62.1,
trend: 'up',
growth: 3.8,
avatar: '/default-avatar.svg'
},
{
id: 9,
name: '吴刚',
department: '华南区',
performance: 82100,
deals: 13,
conversionRate: 58.7,
trend: 'down',
growth: -3.2,
avatar: '/default-avatar.svg'
},
{
id: 10,
name: '孙丽',
department: '西南区',
performance: 76800,
deals: 12,
conversionRate: 55.4,
trend: 'stable',
growth: 1.1,
avatar: '/default-avatar.svg'
},
{
id: 11,
name: '马强',
department: '华北区',
performance: 71200,
deals: 11,
conversionRate: 52.3,
trend: 'down',
growth: -6.8,
avatar: '/default-avatar.svg'
},
{
id: 12,
name: '朱敏',
department: '东北区',
performance: 65900,
deals: 10,
conversionRate: 49.1,
trend: 'down',
growth: -8.5,
avatar: '/default-avatar.svg'
},
{
id: 13,
name: '胡伟',
department: '西北区',
performance: 58700,
deals: 9,
conversionRate: 45.8,
trend: 'down',
growth: -12.3,
avatar: '/default-avatar.svg'
},
{
id: 14,
name: '郭芳',
department: '华中区',
performance: 52400,
deals: 8,
conversionRate: 42.6,
trend: 'down',
growth: -15.7,
avatar: '/default-avatar.svg'
},
{
id: 15,
name: '林磊',
department: '华东区',
performance: 45300,
deals: 7,
conversionRate: 38.9,
trend: 'down',
growth: -18.2,
avatar: '/default-avatar.svg'
},
{
id: 16,
name: '何敏',
department: '华南区',
performance: 38100,
deals: 6,
conversionRate: 35.4,
trend: 'down',
growth: -22.1,
avatar: '/default-avatar.svg'
},
{
id: 17,
name: '罗强',
department: '西南区',
performance: 31800,
deals: 5,
conversionRate: 31.7,
trend: 'down',
growth: -25.6,
avatar: '/default-avatar.svg'
},
{
id: 18,
name: '高丽',
department: '华北区',
performance: 25200,
deals: 4,
conversionRate: 28.3,
trend: 'down',
growth: -28.9,
avatar: '/default-avatar.svg'
},
{
id: 19,
name: '宋伟',
department: '东北区',
performance: 18900,
deals: 3,
conversionRate: 24.1,
trend: 'down',
growth: -32.4,
avatar: '/default-avatar.svg'
},
{
id: 20,
name: '谢芳',
department: '西北区',
performance: 12600,
deals: 2,
conversionRate: 19.8,
trend: 'down',
growth: -36.7,
avatar: '/default-avatar.svg'
}
];
const displayData = computed(() => {
const data = props.rankingData.length > 0 ? props.rankingData : mockData;
if (rankingType.value === 'red') {
// 红榜:按业绩从高到低排序
return [...data].sort((a, b) => b.performance - a.performance);
} else {
// 黑榜:按业绩从低到高排序
return [...data].sort((a, b) => a.performance - b.performance);
}
});
const handlePeriodChange = () => {
emit('period-change', rankingPeriod.value);
};
</script>
<style scoped>
.dashboard-card {
background-color: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
padding: 24px;
display: flex;
flex-direction: column;
flex: 1;
height: 400px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 16px;
border-bottom: 2px solid #f0f0f0;
}
.card-header h3 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #2c3e50;
}
.ranking-toggle {
display: flex;
background-color: #f8f9fa;
border-radius: 8px;
padding: 4px;
gap: 2px;
}
.toggle-btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
background-color: transparent;
color: #6c757d;
}
.toggle-btn:hover {
background-color: #e9ecef;
color: #495057;
}
.toggle-btn.active {
background-color: #fff;
color: #2c3e50;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
font-weight: 600;
}
.toggle-btn.active:hover {
background-color: #fff;
color: #2c3e50;
}
.period-select {
border: 1px solid #ddd;
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
background-color: #fff;
cursor: pointer;
transition: border-color 0.3s ease;
}
.period-select:hover {
border-color: #3498db;
}
.period-select:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
.ranking-list {
flex: 1;
max-height: 500px;
overflow-y: auto;
padding-right: 8px;
}
.ranking-list::-webkit-scrollbar {
width: 6px;
}
.ranking-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.ranking-list::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.ranking-list::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.ranking-item {
display: flex;
align-items: center;
border-bottom: 1px solid #f0f0f0;
transition: all 0.3s ease;
border-radius: 8px;
margin-bottom: 8px;
}
.ranking-item:hover {
background-color: #f8f9fa;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.ranking-item.top-three {
background: linear-gradient(135deg, #fff 0%, #f8f9fa 100%);
border: 1px solid #e9ecef;
}
.ranking-item.black-list {
background: linear-gradient(135deg, #fff 0%, #fdf2f2 100%);
border: 1px solid #f5c6cb;
}
.ranking-item.black-list.top-three {
background: linear-gradient(135deg, #fff 0%, #fdf2f2 100%);
border: 1px solid #f5c6cb;
}
.ranking-item.black-list:hover {
background-color: #fef5f5;
}
.ranking-item:last-child {
border-bottom: none;
}
.rank-number {
font-size: 18px;
font-weight: bold;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
border-radius: 50%;
background-color: #f8f9fa;
}
.rank-number.gold {
background: linear-gradient(135deg, #FFD700, #FFA500);
color: #fff;
box-shadow: 0 4px 8px rgba(255, 215, 0, 0.3);
}
.rank-number.silver {
background: linear-gradient(135deg, #C0C0C0, #A8A8A8);
color: #fff;
box-shadow: 0 4px 8px rgba(192, 192, 192, 0.3);
}
.rank-number.bronze {
background: linear-gradient(135deg, #CD7F32, #B8860B);
color: #fff;
box-shadow: 0 4px 8px rgba(205, 127, 50, 0.3);
}
.crown, .medal {
font-size: 20px;
}
.employee-avatar {
width: 48px;
height: 48px;
margin-right: 16px;
}
.employee-avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e9ecef;
}
.employee-info {
flex-grow: 1;
margin-right: 16px;
}
.employee-name {
font-weight: 600;
font-size: 16px;
color: #2c3e50;
margin-bottom: 4px;
}
.employee-dept {
font-size: 13px;
color: #7f8c8d;
margin-bottom: 6px;
}
.employee-stats {
display: flex;
gap: 12px;
}
.employee-stats span {
font-size: 12px;
color: #95a5a6;
background-color: #ecf0f1;
padding: 2px 6px;
border-radius: 4px;
}
.performance-section {
text-align: right;
min-width: 120px;
}
.performance-value {
font-weight: 700;
font-size: 18px;
color: #2c3e50;
margin-bottom: 4px;
}
.performance-trend {
font-size: 12px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
}
.performance-trend.up {
color: #27ae60;
background-color: #d5f4e6;
}
.performance-trend.down {
color: #e74c3c;
background-color: #fdeaea;
}
.performance-trend.stable {
color: #f39c12;
background-color: #fef9e7;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #95a5a6;
}
.empty-state p {
margin: 0;
font-size: 16px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.dashboard-card {
padding: 16px;
}
.card-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.ranking-item {
padding: 12px 0;
}
.employee-avatar {
width: 40px;
height: 40px;
margin-right: 12px;
}
.employee-name {
font-size: 14px;
}
.performance-value {
font-size: 16px;
}
.employee-stats {
flex-direction: column;
gap: 4px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
<template>
<div class="dashboard-card">
<div class="card-header">
<h3>团队业绩排行榜</h3>
<select v-model="rankingPeriod" class="period-select">
<option value="month">本期</option>
<option value="month">月度</option>
<option value="month">年度</option>
</select>
</div>
<div class="ranking-list">
<div v-for="(item, index) in rankingData.slice(0, 4)" :key="item.id" class="ranking-item">
<div class="rank-number" :class="getRankClass(index)">
{{ index + 1 }}
</div>
<div class="employee-info">
<div class="employee-name">{{ item.name }}</div>
<div class="employee-dept">{{ item.department }}</div>
</div>
<div class="performance-value">
¥{{ formatNumber(item.performance) }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, ref } from 'vue';
defineProps({
rankingData: Array,
formatNumber: Function,
getRankClass: Function
});
const rankingPeriod = ref('month');
</script>
<style scoped>
.dashboard-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 20px;
display: flex;
flex-direction: column;
flex: 1;
height: 400px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-header h3 {
margin: 0;
font-size: 18px;
}
.period-select {
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 10px;
}
.ranking-list .ranking-item {
display: flex;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
.ranking-list .ranking-item:last-child {
border-bottom: none;
}
.rank-number {
font-size: 16px;
font-weight: bold;
width: 30px;
text-align: center;
margin-right: 15px;
}
.rank-number.gold {
color: #FFD700;
}
.rank-number.silver {
color: #C0C0C0;
}
.rank-number.bronze {
color: #CD7F32;
}
.employee-info {
flex-grow: 1;
}
.employee-name {
font-weight: bold;
}
.employee-dept {
font-size: 12px;
color: #666;
}
.performance-value {
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div class="dashboard-card">
<div class="card-header">
<h3>销售实时进度</h3>
</div>
<div class="sales-progress-tips">
<div class="tip-item success">
<i class="icon-check-circle"></i>
<span>{{ salesData.successTip }}</span>
</div>
<div class="tip-item warning">
<i class="icon-alert-circle"></i>
<span>{{ salesData.warningTip }}</span>
</div>
<div class="tip-item info">
<i class="icon-info-circle"></i>
<span>{{ salesData.infoTip }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
defineProps({
salesData: Object
});
</script>
<style scoped>
.dashboard-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 20px;
display: flex;
flex-direction: column;
flex: 1;
height: 350px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-header h3 {
margin: 0;
font-size: 18px;
}
.metric-period {
font-size: 14px;
color: #666;
}
.sales-progress-tips {
display: flex;
flex-direction: column;
gap: 10px;
}
.tip-item {
display: flex;
align-items: center;
font-size: 14px;
}
.tip-item i {
margin-right: 8px;
font-size: 18px;
}
.tip-item.success {
color: #4CAF50;
}
.tip-item.warning {
color: #FF9800;
}
.tip-item.info {
color: #2196F3;
}
.icon-check-circle::before { content: '✔'; }
.icon-alert-circle::before { content: '⚠'; }
.icon-info-circle::before { content: ''; }
</style>

View File

@@ -0,0 +1,121 @@
<template>
<div class="dashboard-card">
<div class="card-header">
<h3>下发任务</h3>
<button class="add-task-btn" @click="$emit('show-task-modal')">
<i class="icon-plus"></i> 新建任务
</button>
</div>
<div class="task-list compact">
<div v-for="task in tasks.slice(0, 3)" :key="task.id" class="task-item">
<div class="task-content">
<div class="task-title">{{ task.title }}</div>
<div class="task-meta" style="display: flex; gap: 15px;">
<span class="assignee">分配给: {{ task.assignee }}</span>
<span class="deadline">截止: {{ formatDate(task.deadline) }}</span>
</div>
</div>
<div class="task-status" :class="task.status">
{{ getTaskStatusText(task.status) }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
tasks: Array,
formatDate: Function,
getTaskStatusText: Function
});
defineEmits(['show-task-modal']);
</script>
<style scoped>
.dashboard-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 20px;
display: flex;
flex-direction: column;
flex: 1;
height: 350px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-header h3 {
margin: 0;
font-size: 18px;
}
.add-task-btn {
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
}
.add-task-btn i {
margin-right: 4px;
}
.task-list.compact .task-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
.task-list.compact .task-item:last-child {
border-bottom: none;
}
.task-title {
font-weight: bold;
}
.task-meta {
font-size: 12px;
color: #666;
}
.task-status {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
}
.task-status.pending {
background-color: #FFC107;
color: #fff;
}
.task-status.completed {
background-color: #4CAF50;
color: #fff;
}
.task-status.overdue {
background-color: #F44336;
color: #fff;
}
.icon-plus::before { content: '+'; }
</style>

File diff suppressed because it is too large Load Diff