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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff