feat: 初始化Vue3项目并添加核心功能模块
新增项目基础结构,包括Vue3、Pinia、Element Plus等核心依赖 添加路由配置和用户认证状态管理 实现销售数据看板、客户画像、团队管理等核心功能模块 集成图表库和API请求工具,完成基础样式配置
This commit is contained in:
255
my-vue-app/src/views/topOne/components/CampManagement.vue
Normal file
255
my-vue-app/src/views/topOne/components/CampManagement.vue
Normal 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>
|
||||
104
my-vue-app/src/views/topOne/components/CommunicationData.vue
Normal file
104
my-vue-app/src/views/topOne/components/CommunicationData.vue
Normal 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>
|
||||
119
my-vue-app/src/views/topOne/components/CustomerProfile.vue
Normal file
119
my-vue-app/src/views/topOne/components/CustomerProfile.vue
Normal 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>
|
||||
93
my-vue-app/src/views/topOne/components/DataDetail.vue
Normal file
93
my-vue-app/src/views/topOne/components/DataDetail.vue
Normal 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>
|
||||
47
my-vue-app/src/views/topOne/components/DataDetailCard.vue
Normal file
47
my-vue-app/src/views/topOne/components/DataDetailCard.vue
Normal 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>
|
||||
233
my-vue-app/src/views/topOne/components/DataTable.vue
Normal file
233
my-vue-app/src/views/topOne/components/DataTable.vue
Normal 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>
|
||||
124
my-vue-app/src/views/topOne/components/DetailedDataTable.vue
Normal file
124
my-vue-app/src/views/topOne/components/DetailedDataTable.vue
Normal 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>
|
||||
357
my-vue-app/src/views/topOne/components/FunnelChart.vue
Normal file
357
my-vue-app/src/views/topOne/components/FunnelChart.vue
Normal 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>
|
||||
349
my-vue-app/src/views/topOne/components/KpiMetrics.vue
Normal file
349
my-vue-app/src/views/topOne/components/KpiMetrics.vue
Normal 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>
|
||||
631
my-vue-app/src/views/topOne/components/PersonalSalesRanking.vue
Normal file
631
my-vue-app/src/views/topOne/components/PersonalSalesRanking.vue
Normal 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>
|
||||
1085
my-vue-app/src/views/topOne/components/QualityCalls.vue
Normal file
1085
my-vue-app/src/views/topOne/components/QualityCalls.vue
Normal file
File diff suppressed because it is too large
Load Diff
117
my-vue-app/src/views/topOne/components/RankingList.vue
Normal file
117
my-vue-app/src/views/topOne/components/RankingList.vue
Normal 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>
|
||||
92
my-vue-app/src/views/topOne/components/SalesProgress.vue
Normal file
92
my-vue-app/src/views/topOne/components/SalesProgress.vue
Normal 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>
|
||||
121
my-vue-app/src/views/topOne/components/TaskList.vue
Normal file
121
my-vue-app/src/views/topOne/components/TaskList.vue
Normal 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>
|
||||
2053
my-vue-app/src/views/topOne/topone.vue
Normal file
2053
my-vue-app/src/views/topOne/topone.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user