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

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