feat: 初始化Vue3项目并添加核心功能模块
新增项目基础结构,包括Vue3、Pinia、Element Plus等核心依赖 添加路由配置和用户认证状态管理 实现销售数据看板、客户画像、团队管理等核心功能模块 集成图表库和API请求工具,完成基础样式配置
This commit is contained in:
992
my-vue-app/src/views/person/components/CustomerDetail.vue
Normal file
992
my-vue-app/src/views/person/components/CustomerDetail.vue
Normal file
@@ -0,0 +1,992 @@
|
||||
<template>
|
||||
<div class="customer-detail-container">
|
||||
<div v-if="selectedContact" class="customer-detail-content">
|
||||
<!-- 头部信息 -->
|
||||
<div class="customer-header">
|
||||
<h3>{{ selectedContact.name }}</h3>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
@click="startBasicAnalysis"
|
||||
class="analysis-button"
|
||||
:disabled="isBasicAnalysisLoading"
|
||||
>
|
||||
{{ isBasicAnalysisLoading ? '基础分析中...' : '基础信息分析' }}
|
||||
</button>
|
||||
<button
|
||||
@click="startSopAnalysis"
|
||||
class="analysis-button sop-button"
|
||||
:disabled="isSopAnalysisLoading"
|
||||
>
|
||||
{{ isSopAnalysisLoading ? 'SOP分析中...' : 'SOP通话分析' }}
|
||||
</button>
|
||||
<button
|
||||
@click="startDemandAnalysis"
|
||||
class="analysis-button demand-button"
|
||||
:disabled="isDemandAnalysisLoading"
|
||||
>
|
||||
{{ isDemandAnalysisLoading ? '诉求分析中...' : '客户诉求分析' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分析区域 -->
|
||||
<div class="analysis-areas">
|
||||
<!-- 上方两个区域 -->
|
||||
<div class="top-row">
|
||||
<!-- 基础信息分析 -->
|
||||
<div class="analysis-section basic-analysis">
|
||||
<div class="section-header">
|
||||
<h4>基础信息分析</h4>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="text-content" v-if="basicAnalysisResult">
|
||||
<div class="analysis-text" v-html="formattedBasicAnalysis"></div>
|
||||
</div>
|
||||
<div class="placeholder-text" v-else>
|
||||
<p>点击"基础信息分析"按钮开始分析客户基础信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SOP通话分析 -->
|
||||
<div class="analysis-section sop-analysis">
|
||||
<div class="section-header">
|
||||
<h4>SOP通话分析</h4>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="text-content" v-if="sopAnalysisResult">
|
||||
<div class="analysis-text" v-html="formattedSopAnalysis"></div>
|
||||
</div>
|
||||
<div class="placeholder-text" v-else>
|
||||
<p>点击"SOP通话分析"按钮开始分析通话记录</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下方整行区域 -->
|
||||
<div class="bottom-row">
|
||||
<!-- 客户诉求分析 -->
|
||||
<div class="analysis-section demand-analysis">
|
||||
<div class="section-header">
|
||||
<h4>客户诉求分析</h4>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="text-content" v-if="demandAnalysisResult">
|
||||
<div class="analysis-text" v-html="formattedDemandAnalysis"></div>
|
||||
</div>
|
||||
<div class="placeholder-text" v-else>
|
||||
<p>点击"客户诉求分析"按钮开始深度分析客户需求和诉求</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未选择客户时的提示 -->
|
||||
<div v-else class="no-selection">
|
||||
<p>请选择一个客户查看详情</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { SimpleChatService } from '@/utils/ChatService.js';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
// 定义props
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
// 分析结果状态
|
||||
const basicAnalysisResult = ref(''); // 基础信息分析结果
|
||||
const sopAnalysisResult = ref(''); // SOP通话分析结果
|
||||
const demandAnalysisResult = ref(''); // 客户诉求分析结果
|
||||
|
||||
// 加载状态
|
||||
const isBasicAnalysisLoading = ref(false); // 基础分析加载状态
|
||||
const isSopAnalysisLoading = ref(false); // SOP分析加载状态
|
||||
const isDemandAnalysisLoading = ref(false); // 诉求分析加载状态
|
||||
|
||||
// Dify API配置
|
||||
const DIFY_API_KEY_01 = 'app-wbR1P1j6kvdBK8Q1qXzdswzP';
|
||||
const DIFY_API_KEY = 'app-37VXHRieOnq17BSury9ONavG';
|
||||
// 初始化ChatService
|
||||
const chatService_01 = new SimpleChatService(DIFY_API_KEY_01);
|
||||
const chatService = new SimpleChatService(DIFY_API_KEY);
|
||||
|
||||
// 初始化markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true
|
||||
});
|
||||
|
||||
// 计算属性:格式化基础分析结果
|
||||
const formattedBasicAnalysis = computed(() => {
|
||||
if (!basicAnalysisResult.value) return '';
|
||||
return md.render(basicAnalysisResult.value);
|
||||
});
|
||||
|
||||
// 计算属性:格式化SOP分析结果
|
||||
const formattedSopAnalysis = computed(() => {
|
||||
if (!sopAnalysisResult.value) return '';
|
||||
return md.render(sopAnalysisResult.value);
|
||||
});
|
||||
|
||||
// 计算属性:格式化诉求分析结果
|
||||
const formattedDemandAnalysis = computed(() => {
|
||||
if (!demandAnalysisResult.value) return '';
|
||||
return md.render(demandAnalysisResult.value);
|
||||
});
|
||||
|
||||
// 监听selectedContact变化,重置所有分析结果
|
||||
watch(() => props.selectedContact, (newContact) => {
|
||||
if (newContact) {
|
||||
// 重置所有分析状态
|
||||
basicAnalysisResult.value = '';
|
||||
sopAnalysisResult.value = '';
|
||||
demandAnalysisResult.value = '';
|
||||
isBasicAnalysisLoading.value = false;
|
||||
isSopAnalysisLoading.value = false;
|
||||
isDemandAnalysisLoading.value = false;
|
||||
} else {
|
||||
// 清空所有结果
|
||||
basicAnalysisResult.value = '';
|
||||
sopAnalysisResult.value = '';
|
||||
demandAnalysisResult.value = '';
|
||||
isBasicAnalysisLoading.value = false;
|
||||
isSopAnalysisLoading.value = false;
|
||||
isDemandAnalysisLoading.value = false;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// 基础信息分析
|
||||
const startBasicAnalysis = async () => {
|
||||
if (!props.selectedContact) return;
|
||||
|
||||
isBasicAnalysisLoading.value = true;
|
||||
basicAnalysisResult.value = '';
|
||||
|
||||
const query = `请对客户进行基础信息分析:
|
||||
客户姓名:${props.selectedContact.name}
|
||||
联系电话:${props.selectedContact.phone || '未提供'}
|
||||
邮箱:${props.selectedContact.email || '未提供'}
|
||||
公司:${props.selectedContact.company || '未提供'}
|
||||
职位:${props.selectedContact.position || '未提供'}
|
||||
销售阶段:${props.selectedContact.salesStage || '未知'}
|
||||
健康度:${props.selectedContact.health || '未知'}%
|
||||
|
||||
请分析客户的基本情况、背景信息和初步画像。`;
|
||||
|
||||
try {
|
||||
await chatService_01.sendMessage(
|
||||
query,
|
||||
(update) => {
|
||||
basicAnalysisResult.value = update.content;
|
||||
},
|
||||
() => {
|
||||
isBasicAnalysisLoading.value = false;
|
||||
console.log('基础信息分析完成');
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('基础信息分析失败:', error);
|
||||
basicAnalysisResult.value = `分析失败: ${error.message}`;
|
||||
isBasicAnalysisLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// SOP通话分析
|
||||
const startSopAnalysis = async () => {
|
||||
if (!props.selectedContact) return;
|
||||
|
||||
isSopAnalysisLoading.value = true;
|
||||
sopAnalysisResult.value = '';
|
||||
|
||||
const query = `请对客户 ${props.selectedContact.name} 进行SOP通话分析:
|
||||
|
||||
基于标准销售流程(SOP),分析以下方面:
|
||||
1. 通话质量评估
|
||||
2. 销售流程执行情况
|
||||
3. 客户响应度分析
|
||||
4. 沟通效果评价
|
||||
5. 改进建议
|
||||
|
||||
客户当前状态:${props.selectedContact.salesStage || '未知'}
|
||||
健康度:${props.selectedContact.health || '未知'}%`;
|
||||
|
||||
try {
|
||||
await chatService.sendMessage(
|
||||
query,
|
||||
(update) => {
|
||||
sopAnalysisResult.value = update.content;
|
||||
},
|
||||
() => {
|
||||
isSopAnalysisLoading.value = false;
|
||||
console.log('SOP通话分析完成');
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('SOP通话分析失败:', error);
|
||||
sopAnalysisResult.value = `分析失败: ${error.message}`;
|
||||
isSopAnalysisLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 客户诉求分析
|
||||
const startDemandAnalysis = async () => {
|
||||
if (!props.selectedContact) return;
|
||||
|
||||
isDemandAnalysisLoading.value = true;
|
||||
demandAnalysisResult.value = '';
|
||||
|
||||
const query = `请对客户 ${props.selectedContact.name} 进行深度诉求分析:
|
||||
|
||||
请从以下维度分析客户的真实需求和诉求:
|
||||
1. 显性需求分析(客户明确表达的需求)
|
||||
2. 隐性需求挖掘(潜在的、未明确表达的需求)
|
||||
3. 痛点识别(客户面临的主要问题和挑战)
|
||||
4. 决策因素分析(影响客户决策的关键因素)
|
||||
5. 价值期望(客户期望获得的价值和收益)
|
||||
6. 风险顾虑(客户可能的担忧和顾虑)
|
||||
7. 个性化建议(针对性的解决方案建议)
|
||||
|
||||
客户信息:
|
||||
姓名:${props.selectedContact.name}
|
||||
公司:${props.selectedContact.company || '未提供'}
|
||||
职位:${props.selectedContact.position || '未提供'}
|
||||
销售阶段:${props.selectedContact.salesStage || '未知'}
|
||||
健康度:${props.selectedContact.health || '未知'}%`;
|
||||
|
||||
try {
|
||||
await chatService.sendMessage(
|
||||
query,
|
||||
(update) => {
|
||||
demandAnalysisResult.value = update.content;
|
||||
},
|
||||
() => {
|
||||
isDemandAnalysisLoading.value = false;
|
||||
console.log('客户诉求分析完成');
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('客户诉求分析失败:', error);
|
||||
demandAnalysisResult.value = `分析失败: ${error.message}`;
|
||||
isDemandAnalysisLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Color Palette
|
||||
$slate-50: #f8fafc;
|
||||
$slate-100: #f1f5f9;
|
||||
$slate-200: #e2e8f0;
|
||||
$slate-300: #cbd5e1;
|
||||
$slate-400: #94a3b8;
|
||||
$slate-500: #64748b;
|
||||
$slate-600: #475569;
|
||||
$slate-700: #334155;
|
||||
$slate-800: #1e293b;
|
||||
$white: #ffffff;
|
||||
|
||||
$blue: #3b82f6;
|
||||
$green: #22c55e;
|
||||
$amber: #f59e0b;
|
||||
$red: #ef4444;
|
||||
$indigo: #4f46e5;
|
||||
$purple: #a855f7;
|
||||
|
||||
// 容器样式
|
||||
.customer-detail-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
|
||||
// PC端保持一致布局
|
||||
@media (min-width: 1024px) {
|
||||
// padding: 24px;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@media (max-width: 1023px) and (min-width: 769px) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// 客户详情内容
|
||||
.customer-detail-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// 客户头部信息
|
||||
.customer-header {
|
||||
background: $white;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid $slate-200;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
// PC端保持一致布局
|
||||
@media (min-width: 1024px) {
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@media (max-width: 1023px) and (min-width: 769px) {
|
||||
padding: 18px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: $slate-800;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
|
||||
// PC端保持一致布局
|
||||
@media (min-width: 1024px) {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@media (max-width: 1023px) and (min-width: 769px) {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.analysis-button {
|
||||
padding: 10px 16px;
|
||||
background: $blue;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
// PC端保持一致布局
|
||||
@media (min-width: 1024px) {
|
||||
padding: 12px 20px;
|
||||
font-size: 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@media (max-width: 1023px) and (min-width: 769px) {
|
||||
padding: 11px 18px;
|
||||
font-size: 14px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: $slate-400;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&.sop-button {
|
||||
background: $green;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #16a34a;
|
||||
}
|
||||
}
|
||||
|
||||
&.demand-button {
|
||||
background: $purple;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #9333ea;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分析区域
|
||||
.analysis-areas {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// 上方行
|
||||
.top-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
height: 45%;
|
||||
|
||||
// PC端保持一致布局
|
||||
@media (min-width: 1024px) {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@media (max-width: 1023px) and (min-width: 769px) {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// 下方行
|
||||
.bottom-row {
|
||||
height: 55%;
|
||||
}
|
||||
|
||||
// 分析区域样式
|
||||
.analysis-section {
|
||||
background: $white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid $slate-200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
// PC端保持一致布局
|
||||
@media (min-width: 1024px) {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@media (max-width: 1023px) and (min-width: 769px) {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 12px 16px;
|
||||
background: $slate-50;
|
||||
border-bottom: 1px solid $slate-200;
|
||||
|
||||
// PC端保持一致布局
|
||||
@media (min-width: 1024px) {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@media (max-width: 1023px) and (min-width: 769px) {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $slate-700;
|
||||
|
||||
// PC端保持一致布局
|
||||
@media (min-width: 1024px) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@media (max-width: 1023px) and (min-width: 769px) {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
|
||||
// PC端保持一致布局
|
||||
@media (min-width: 1024px) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@media (max-width: 1023px) and (min-width: 769px) {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
height: 100%;
|
||||
|
||||
.analysis-text {
|
||||
color: $slate-700;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
|
||||
// PC端保持一致布局
|
||||
@media (min-width: 1024px) {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@media (max-width: 1023px) and (min-width: 769px) {
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: $slate-50;
|
||||
border-radius: 6px;
|
||||
border: 2px dashed $slate-200;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: $slate-500;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
|
||||
// PC端保持一致布局
|
||||
@media (min-width: 1024px) {
|
||||
font-size: 15px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@media (max-width: 1023px) and (min-width: 769px) {
|
||||
font-size: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
font-size: 13px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
font-size: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不同分析区域的主题色
|
||||
&.basic-analysis {
|
||||
.section-header {
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
|
||||
h4 {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.sop-analysis {
|
||||
.section-header {
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||
|
||||
h4 {
|
||||
color: $green;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.demand-analysis {
|
||||
.section-header {
|
||||
background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%);
|
||||
|
||||
h4 {
|
||||
color: $purple;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Markdown样式
|
||||
.analysis-text {
|
||||
// Markdown样式
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
font-weight: 600;
|
||||
color: $slate-800;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1 { font-size: 1.25rem; }
|
||||
h2 { font-size: 1.125rem; }
|
||||
h3 { font-size: 1rem; }
|
||||
h4 { font-size: 0.875rem; }
|
||||
h5 { font-size: 0.75rem; }
|
||||
h6 { font-size: 0.75rem; }
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
|
||||
li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1rem 0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-left: 4px solid $blue;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
code {
|
||||
background: $slate-100;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: $slate-100;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
color: $slate-800;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $blue;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1.5rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid $slate-200;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid $slate-200;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: $slate-50;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 未选择状态
|
||||
.no-selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: $slate-50;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed $slate-200;
|
||||
color: $slate-500;
|
||||
|
||||
// PC端保持一致布局
|
||||
@media (min-width: 1024px) {
|
||||
border-radius: 12px;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@media (max-width: 1023px) and (min-width: 769px) {
|
||||
border-radius: 10px;
|
||||
min-height: 450px;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
height: 400px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
height: 300px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
|
||||
// PC端保持一致布局
|
||||
@media (min-width: 1024px) {
|
||||
font-size: 1.125rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@media (max-width: 1023px) and (min-width: 769px) {
|
||||
font-size: 1.0625rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h2.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
color: $slate-700;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-weight: 600;
|
||||
color: $slate-700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
// Context Panel
|
||||
.section-card {
|
||||
background-color: $white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
// padding: 1rem;
|
||||
// margin-top: 12px;
|
||||
}
|
||||
|
||||
// 分析区域布局优化
|
||||
.analysis-areas {
|
||||
// PC端保持一致布局
|
||||
@media (min-width: 1024px) {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@media (max-width: 1023px) and (min-width: 769px) {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// 下方行适配
|
||||
.bottom-row {
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
height: auto;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
// 小屏移动端适配
|
||||
@media (max-width: 480px) {
|
||||
min-height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
577
my-vue-app/src/views/person/components/FloatingTodo.vue
Normal file
577
my-vue-app/src/views/person/components/FloatingTodo.vue
Normal file
@@ -0,0 +1,577 @@
|
||||
<template>
|
||||
<div class="floating-todo" :class="{ 'expanded': isExpanded }">
|
||||
<!-- 悬浮按钮 -->
|
||||
<div class="floating-btn" @click="toggleExpanded">
|
||||
<i class="icon-calendar">📅</i>
|
||||
<span class="todo-count" v-if="!isExpanded">{{ todayTodos.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 展开的内容面板 -->
|
||||
<div class="todo-panel" v-show="isExpanded">
|
||||
<div class="panel-header">
|
||||
<h3>今日待办</h3>
|
||||
<button class="close-btn" @click="toggleExpanded">×</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<!-- 今日待办列表 -->
|
||||
<div class="todo-section">
|
||||
<h4>待办事项 ({{ todayTodos.length }})</h4>
|
||||
<div class="todo-list">
|
||||
<div
|
||||
v-for="todo in todayTodos"
|
||||
:key="todo.id"
|
||||
class="todo-item"
|
||||
:class="{ 'completed': todo.completed }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="todo.completed"
|
||||
@change="updateTodo(todo)"
|
||||
>
|
||||
<span class="todo-text">{{ todo.text }}</span>
|
||||
<span class="todo-time">{{ todo.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加新待办 -->
|
||||
<div class="add-todo-section">
|
||||
<h4>添加待办</h4>
|
||||
<div class="add-todo-form">
|
||||
<input
|
||||
v-model="newTodoText"
|
||||
type="text"
|
||||
placeholder="输入待办事项..."
|
||||
@keyup.enter="addTodo"
|
||||
class="todo-input"
|
||||
>
|
||||
<input
|
||||
v-model="newTodoTime"
|
||||
type="time"
|
||||
class="time-input"
|
||||
>
|
||||
<button @click="addTodo" class="add-btn">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<div class="quick-actions">
|
||||
<button @click="addQuickTodo('回访重点客户')" class="quick-btn">回访重点客户</button>
|
||||
<button @click="addQuickTodo('整理客户资料')" class="quick-btn">整理客户资料</button>
|
||||
<button @click="addQuickTodo('准备明日计划')" class="quick-btn">准备明日计划</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
|
||||
// 状态管理
|
||||
const isExpanded = ref(false);
|
||||
const newTodoText = ref('');
|
||||
const newTodoTime = ref('');
|
||||
|
||||
// 待办事项数据
|
||||
const todos = reactive([
|
||||
{
|
||||
id: 1,
|
||||
text: '回访王女士 - 付定金阶段',
|
||||
time: '10:00',
|
||||
completed: false,
|
||||
date: new Date().toDateString()
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
text: '联系李先生 - 课程咨询',
|
||||
time: '14:00',
|
||||
completed: false,
|
||||
date: new Date().toDateString()
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
text: '准备张总的合同材料',
|
||||
time: '16:00',
|
||||
completed: true,
|
||||
date: new Date().toDateString()
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
text: '整理本周客户跟进报告',
|
||||
time: '18:00',
|
||||
completed: false,
|
||||
date: new Date().toDateString()
|
||||
}
|
||||
]);
|
||||
|
||||
// 计算今日待办
|
||||
const todayTodos = computed(() => {
|
||||
const today = new Date().toDateString();
|
||||
return todos.filter(todo => todo.date === today);
|
||||
});
|
||||
|
||||
// 方法
|
||||
const toggleExpanded = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
|
||||
const updateTodo = (todo) => {
|
||||
// 这里可以添加保存到本地存储或发送到服务器的逻辑
|
||||
console.log('Todo updated:', todo);
|
||||
};
|
||||
|
||||
const addTodo = () => {
|
||||
if (newTodoText.value.trim()) {
|
||||
const newTodo = {
|
||||
id: Date.now(),
|
||||
text: newTodoText.value,
|
||||
time: newTodoTime.value || '09:00',
|
||||
completed: false,
|
||||
date: new Date().toDateString()
|
||||
};
|
||||
todos.push(newTodo);
|
||||
newTodoText.value = '';
|
||||
newTodoTime.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const addQuickTodo = (text) => {
|
||||
const newTodo = {
|
||||
id: Date.now(),
|
||||
text: text,
|
||||
time: '09:00',
|
||||
completed: false,
|
||||
date: new Date().toDateString()
|
||||
};
|
||||
todos.push(newTodo);
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 可以从本地存储加载数据
|
||||
const savedTodos = localStorage.getItem('floating-todos');
|
||||
if (savedTodos) {
|
||||
const parsed = JSON.parse(savedTodos);
|
||||
todos.splice(0, todos.length, ...parsed);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听数据变化,保存到本地存储
|
||||
const saveTodos = () => {
|
||||
localStorage.setItem('floating-todos', JSON.stringify(todos));
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.floating-todo {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 1000;
|
||||
|
||||
.floating-btn {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 25px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
.icon-calendar {
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.todo-count {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background: #ff4757;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.todo-panel {
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
left: 0;
|
||||
width: 350px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
animation: slideDown 0.3s ease;
|
||||
|
||||
.panel-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 20px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
.todo-section {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.todo-list {
|
||||
.todo-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.completed {
|
||||
opacity: 0.6;
|
||||
|
||||
.todo-text {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
margin-right: 10px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.todo-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.todo-time {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-todo-section {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add-todo-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.todo-input {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
.time-input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
padding: 8px 16px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #5a6fd8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
.quick-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: #e9ecef;
|
||||
border-color: #dee2e6;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
.floating-btn {
|
||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.floating-todo {
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
|
||||
.floating-btn {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
|
||||
.icon-calendar {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.todo-count {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 11px;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
.todo-panel {
|
||||
width: 300px;
|
||||
top: 60px;
|
||||
|
||||
.panel-header {
|
||||
padding: 12px 15px;
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 15px;
|
||||
|
||||
h4 {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
padding: 8px 0;
|
||||
|
||||
.todo-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.todo-time {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.todo-input {
|
||||
font-size: 13px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
font-size: 13px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.add-btn, .quick-btn {
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕优化 */
|
||||
@media (max-width: 480px) {
|
||||
.floating-todo {
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
|
||||
.floating-btn {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
|
||||
.icon-calendar {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.todo-count {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.todo-panel {
|
||||
width: 280px;
|
||||
top: 55px;
|
||||
|
||||
.panel-header {
|
||||
padding: 10px 12px;
|
||||
|
||||
h3 {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 12px;
|
||||
|
||||
h4 {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
padding: 6px 0;
|
||||
|
||||
.todo-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.todo-time {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-todo-form {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.todo-input, .time-input {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.quick-btn {
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
650
my-vue-app/src/views/person/components/PersonalDashboard.vue
Normal file
650
my-vue-app/src/views/person/components/PersonalDashboard.vue
Normal file
@@ -0,0 +1,650 @@
|
||||
<template>
|
||||
<div class="personal-dashboard">
|
||||
<!-- 头部标题 -->
|
||||
<div class="dashboard-header">
|
||||
<h2>个人工作仪表板</h2>
|
||||
</div>
|
||||
|
||||
<!-- 核心KPI & 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<!-- 核心KPI -->
|
||||
<div class="stat-card kpi-card">
|
||||
<h3 class="card-title">核心KPI</h3>
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi-item">
|
||||
<div class="kpi-value">{{ props.kpiData.totalCalls }}</div>
|
||||
<p>今日通话</p>
|
||||
</div>
|
||||
<div class="kpi-item">
|
||||
<div class="kpi-value">{{ props.kpiData.successRate }}%</div>
|
||||
<p>成功率</p>
|
||||
</div>
|
||||
<div class="kpi-item">
|
||||
<div class="kpi-value">{{ props.kpiData.avgDuration }}<span class="kpi-unit">min</span></div>
|
||||
<p>平均时长</p>
|
||||
</div>
|
||||
<div class="kpi-item">
|
||||
<div class="kpi-value">{{ props.kpiData.conversionRate }}</div>
|
||||
<p>转化率</p>
|
||||
</div>
|
||||
<div class="kpi-item">
|
||||
<div class="kpi-value">{{ props.kpiData.assignedData }}</div>
|
||||
<p>本期分配数据</p>
|
||||
</div>
|
||||
<div class="kpi-item">
|
||||
<div class="kpi-value">{{ props.kpiData.wechatAddRate }}</div>
|
||||
<p>加微率</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 统计指标 -->
|
||||
<StatisticData
|
||||
:customerCommunicationRate="props.statisticsData.customerCommunicationRate"
|
||||
:averageResponseTime="props.statisticsData.averageResponseTime"
|
||||
:timeoutResponseRate="props.statisticsData.timeoutResponseRate"
|
||||
:severeTimeoutRate="props.statisticsData.severeTimeoutRate"
|
||||
:formCompletionRate="props.statisticsData.formCompletionRate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 图表和功能区 -->
|
||||
<div class="charts-section">
|
||||
<!-- 客户迫切解决的问题排行榜 -->
|
||||
<div class="chart-container">
|
||||
<div class="chart-header">
|
||||
<h3>客户迫切解决的问题</h3>
|
||||
</div>
|
||||
<div class="chart-content">
|
||||
<div class="problem-ranking">
|
||||
<div v-for="(item, index) in sortedProblemData" :key="item.name" class="ranking-item" :class="getRankingClass(index)">
|
||||
<div class="rank-number">
|
||||
<span class="rank-badge" :class="getRankBadgeClass(index)">{{ index + 1 }}</span>
|
||||
</div>
|
||||
<div class="problem-info">
|
||||
<div class="problem-name">{{ item.name }}</div>
|
||||
<div class="problem-count">{{ item.value }}次咨询</div>
|
||||
</div>
|
||||
<div class="problem-percentage">
|
||||
<span class="percentage">{{ getPercentage(item.value) }}%</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: getPercentage(item.value) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 销售漏斗 -->
|
||||
<div class="chart-container">
|
||||
<div class="chart-header">
|
||||
<h3>销售漏斗</h3>
|
||||
</div>
|
||||
<div class="chart-content">
|
||||
<canvas ref="personalFunnelChartCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 黄金联络时段 -->
|
||||
<div class="chart-container">
|
||||
<div class="chart-header">
|
||||
<h3>黄金联络时段</h3>
|
||||
</div>
|
||||
<div class="chart-content">
|
||||
<canvas ref="contactTimeChartCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||
import StatisticData from './StatisticData.vue';
|
||||
import * as echarts from 'echarts';
|
||||
import Chart from 'chart.js/auto';
|
||||
import {getTableFillingRate,getAverageResponseTime,getWeeklyActiveCommunicationRate,getTimeoutResponseRate} from "@/api/api.js"
|
||||
import { useUserStore } from "@/stores/user";
|
||||
|
||||
// 用户store
|
||||
const userStore = useUserStore();
|
||||
// 定义props
|
||||
const props = defineProps({
|
||||
kpiData: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
totalCalls: 128,
|
||||
successRate: 75,
|
||||
avgDuration: 8.5,
|
||||
conversionRate: 15,
|
||||
assignedData: 256,
|
||||
wechatAddRate: 68
|
||||
})
|
||||
},
|
||||
funnelData: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
labels: ['线索', '沟通', '意向', '预约', '成交'],
|
||||
data: [200, 150, 90, 40, 12]
|
||||
})
|
||||
},
|
||||
contactTimeData: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
labels: ['9-10点', '10-11点', '11-12点', '14-15点', '15-16点', '16-17点'],
|
||||
data: [65, 85, 80, 92, 75, 60]
|
||||
})
|
||||
},
|
||||
statisticsData: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
customerCommunicationRate: 0,
|
||||
averageResponseTime: 0,
|
||||
timeoutResponseRate: 0,
|
||||
severeTimeoutRate: 0,
|
||||
formCompletionRate: 0
|
||||
})
|
||||
},
|
||||
urgentProblemData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
// Chart.js 实例
|
||||
const chartInstances = {};
|
||||
|
||||
// DOM 元素引用
|
||||
const personalFunnelChartCanvas = ref(null);
|
||||
const contactTimeChartCanvas = ref(null);
|
||||
|
||||
// Chart.js 数据 - 使用props传递的数据
|
||||
const funnelData = computed(() => props.funnelData);
|
||||
const contactTimeData = computed(() => props.contactTimeData);
|
||||
|
||||
// --- 计算属性 ---
|
||||
const sortedProblemData = computed(() => {
|
||||
if (!props.urgentProblemData || !Array.isArray(props.urgentProblemData)) {
|
||||
return [];
|
||||
}
|
||||
return [...props.urgentProblemData].sort((a, b) => b.value - a.value);
|
||||
});
|
||||
const totalProblemCount = computed(() => {
|
||||
if (!props.urgentProblemData || !Array.isArray(props.urgentProblemData)) {
|
||||
return 0;
|
||||
}
|
||||
return props.urgentProblemData.reduce((sum, item) => sum + item.value, 0);
|
||||
});
|
||||
|
||||
// --- 方法 ---
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Chart.js: 创建或更新图表
|
||||
const createOrUpdateChart = (chartId, canvasRef, config) => {
|
||||
if (chartInstances[chartId]) {
|
||||
chartInstances[chartId].destroy();
|
||||
}
|
||||
if (canvasRef.value) {
|
||||
const ctx = canvasRef.value.getContext('2d');
|
||||
chartInstances[chartId] = new Chart(ctx, config);
|
||||
}
|
||||
};
|
||||
|
||||
// Chart.js: 渲染销售漏斗图
|
||||
const renderPersonalFunnelChart = () => {
|
||||
const config = {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: funnelData.labels,
|
||||
datasets: [{
|
||||
label: '数量', data: funnelData.data,
|
||||
backgroundColor: ['rgba(59, 130, 246, 0.8)', 'rgba(16, 185, 129, 0.8)', 'rgba(245, 158, 11, 0.8)', 'rgba(239, 68, 68, 0.8)'],
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { grid: { display: false }, ticks: { color: '#64748b', font: { size: 11 } } },
|
||||
x: { beginAtZero: true, grid: { color: 'rgba(148, 163, 184, 0.2)' }, ticks: { color: '#64748b', font: { size: 11 } } }
|
||||
}
|
||||
}
|
||||
};
|
||||
createOrUpdateChart('personalFunnel', personalFunnelChartCanvas, config);
|
||||
};
|
||||
|
||||
// Chart.js: 渲染黄金联络时段图
|
||||
const renderContactTimeChart = () => {
|
||||
const config = {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: contactTimeData.labels,
|
||||
datasets: [{
|
||||
label: '成功率', data: contactTimeData.data,
|
||||
borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
borderWidth: 3, tension: 0.4, fill: true, pointRadius: 4,
|
||||
pointBackgroundColor: '#10b981', pointBorderColor: '#ffffff', pointBorderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, max: 100, grid: { color: 'rgba(148, 163, 184, 0.2)' }, ticks: { color: '#64748b', font: { size: 11 }, callback: (value) => value + '%' } },
|
||||
x: { grid: { display: false }, ticks: { color: '#64748b', font: { size: 11 } } }
|
||||
}
|
||||
}
|
||||
};
|
||||
createOrUpdateChart('contactTime', contactTimeChartCanvas, config);
|
||||
};
|
||||
|
||||
// 排行榜相关方法
|
||||
const getPercentage = (value) => {
|
||||
if (totalProblemCount.value === 0 || !value) {
|
||||
return '0.0';
|
||||
}
|
||||
return ((value / totalProblemCount.value) * 100).toFixed(1);
|
||||
};
|
||||
const getRankingClass = (index) => ({ 'rank-first': index === 0, 'rank-second': index === 1, 'rank-third': index === 2, 'rank-other': index > 2 });
|
||||
const getRankBadgeClass = (index) => ({ 'badge-gold': index === 0, 'badge-silver': index === 1, 'badge-bronze': index === 2, 'badge-default': index > 2 });
|
||||
|
||||
|
||||
|
||||
// --- 生命周期钩子 ---
|
||||
|
||||
onMounted(() => {
|
||||
renderPersonalFunnelChart();
|
||||
renderContactTimeChart();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
Object.values(chartInstances).forEach(chart => chart.destroy());
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// --- 颜色和变量定义 ---
|
||||
$slate-50: #f8fafc;
|
||||
$slate-100: #f1f5f9;
|
||||
$slate-200: #e2e8f0;
|
||||
$slate-700: #334155;
|
||||
$slate-800: #1e293b;
|
||||
$slate-900: #303133;
|
||||
$gray-400: #909399;
|
||||
$gray-600: #606266;
|
||||
$blue: #409eff;
|
||||
$green: #67c23a;
|
||||
$orange: #e6a23c;
|
||||
$red: #f56c6c;
|
||||
$white: #ffffff;
|
||||
|
||||
.personal-dashboard {
|
||||
padding: 10px;
|
||||
background-color: #f5f7fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
background: $white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 24px;
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: $slate-900;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 统计卡片网格 ---
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: $white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
font-size: 24px;
|
||||
color: $white;
|
||||
&.customer-rate { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
&.response-time { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
|
||||
&.timeout-rate { background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); }
|
||||
&.form-rate { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); }
|
||||
&.severe-timeout-rate { background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%); }
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
.stat-value { font-size: 20px; font-weight: 700; color: $slate-900; margin-bottom: 4px; }
|
||||
.stat-label { font-size: 14px; color: $gray-400; font-weight: 500; }
|
||||
}
|
||||
|
||||
// --- KPI 卡片特定样式 ---
|
||||
.kpi-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: $slate-900;
|
||||
margin: -10px 0 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
// grid-template-rows: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kpi-item {
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
background-color: $slate-50;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid $slate-200;
|
||||
.kpi-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: $slate-800;
|
||||
}
|
||||
.kpi-unit {
|
||||
font-size: 0.875rem;
|
||||
font-weight: normal;
|
||||
color: $gray-400;
|
||||
margin-left: 2px;
|
||||
}
|
||||
p {
|
||||
font-size: 0.875rem;
|
||||
color: $gray-600;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 统计指标卡片特定样式
|
||||
.stats-grid-inner {
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
padding: 1rem 0.5rem;
|
||||
|
||||
.stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: $white;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&.customer-rate { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
&.response-time { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
|
||||
&.timeout-rate { background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); }
|
||||
&.form-rate { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); }
|
||||
&.severe-timeout-rate { background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%); }
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- 图表区域 ---
|
||||
.charts-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: $white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
min-height: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
h3 { margin: 0; color: $slate-900; font-size: 18px; font-weight: 600; }
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-bottom: 20px;
|
||||
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
canvas {
|
||||
max-height: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.chart-select {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid $slate-200;
|
||||
background-color: $slate-50;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
// --- 排行榜样式 ---
|
||||
.problem-ranking {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ranking-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
&:not(:last-child) { border-bottom: 1px solid #f0f2f5; }
|
||||
}
|
||||
.rank-number .rank-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
color: $white;
|
||||
&.badge-gold { background: linear-gradient(135deg, #ffd700, #ffb300); }
|
||||
&.badge-silver { background: linear-gradient(135deg, #c0c0c0, #a8a8a8); }
|
||||
&.badge-bronze { background: linear-gradient(135deg, #cd7f32, #b8860b); }
|
||||
&.badge-default { background: linear-gradient(135deg, #6c757d, #495057); }
|
||||
}
|
||||
.problem-info { flex: 1; margin: 0 16px; }
|
||||
.problem-name { font-size: 15px; font-weight: 500; color: #212529; margin-bottom: 4px; }
|
||||
.problem-count { font-size: 13px; color: #6c757d; }
|
||||
.problem-percentage { min-width: 80px; text-align: right; }
|
||||
.percentage { font-size: 15px; font-weight: bold; color: #495057; margin-bottom: 6px; display: block; }
|
||||
.progress-bar { width: 100%; height: 6px; background: rgba(0,0,0,0.1); border-radius: 3px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, #007bff, #0056b3); border-radius: 3px; }
|
||||
.rank-first .progress-fill { background: linear-gradient(90deg, #ffd700, #ffb300); }
|
||||
.rank-second .progress-fill { background: linear-gradient(90deg, #c0c0c0, #a8a8a8); }
|
||||
.rank-third .progress-fill { background: linear-gradient(90deg, #cd7f32, #b8860b); }
|
||||
|
||||
|
||||
|
||||
// --- 响应式设计 ---
|
||||
@media (max-width: 1200px) {
|
||||
.charts-section { grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.personal-dashboard { padding: 15px; }
|
||||
.stats-grid, .charts-section { grid-template-columns: 1fr; }
|
||||
.stat-card { flex-direction: row; }
|
||||
|
||||
.dashboard-header {
|
||||
padding: 16px;
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.kpi-item {
|
||||
padding: 0.5rem;
|
||||
.kpi-value {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
p {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid-inner {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
min-height: 100px;
|
||||
padding: 0.75rem 0.25rem;
|
||||
|
||||
.stat-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 16px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
padding: 16px 16px 12px;
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/* 小屏幕优化 */
|
||||
@media (max-width: 480px) {
|
||||
.personal-dashboard {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
padding: 12px;
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.kpi-item {
|
||||
padding: 0.75rem;
|
||||
.kpi-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid-inner {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
min-height: 80px;
|
||||
padding: 1rem;
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
|
||||
.stat-icon {
|
||||
margin-bottom: 0;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.charts-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
</style>
|
||||
599
my-vue-app/src/views/person/components/RawDataCards.vue
Normal file
599
my-vue-app/src/views/person/components/RawDataCards.vue
Normal file
@@ -0,0 +1,599 @@
|
||||
<template>
|
||||
<div class="raw-data-cards">
|
||||
<div class="cards-container">
|
||||
<!-- 表单信息卡片 -->
|
||||
<div class="data-card form-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="10,9 9,9 8,9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="card-title">表单信息</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="form-data-list">
|
||||
<div v-for="(field, index) in formFields" :key="index" class="form-field">
|
||||
<span class="field-label">{{ field.label }}:</span>
|
||||
<span class="field-value">{{ field.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天记录和通话录音卡片 -->
|
||||
<div class="data-card communication-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="card-title">沟通记录</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<!-- Tab 切换 -->
|
||||
<div class="tab-container">
|
||||
<div class="tab-buttons">
|
||||
<button
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === 'chat' }"
|
||||
@click="activeTab = 'chat'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
聊天记录
|
||||
</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === 'call' }"
|
||||
@click="activeTab = 'call'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
通话录音
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 内容 -->
|
||||
<div class="tab-content">
|
||||
<!-- 聊天记录内容 -->
|
||||
<div v-if="activeTab === 'chat'" class="chat-content">
|
||||
<div class="content-header">
|
||||
<span class="content-count">共 {{ chatData.count }} 条消息</span>
|
||||
<span class="content-time">最新: {{ chatData.lastMessage }}</span>
|
||||
</div>
|
||||
<div class="message-list">
|
||||
<div v-for="(message, index) in chatMessages" :key="index" class="message-item">
|
||||
<div class="message-header">
|
||||
<span class="message-sender">{{ message.sender }}</span>
|
||||
<span class="message-time">{{ message.time }}</span>
|
||||
</div>
|
||||
<div class="message-text">{{ message.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通话录音内容 -->
|
||||
<div v-if="activeTab === 'call'" class="call-content">
|
||||
<div class="content-header">
|
||||
<span class="content-count">共 {{ callData.count }} 次通话</span>
|
||||
<span class="content-time">总时长: {{ callData.totalDuration }}</span>
|
||||
</div>
|
||||
<div class="call-list">
|
||||
<div v-for="(call, index) in callRecords" :key="index" class="call-item">
|
||||
<div class="call-header">
|
||||
<span class="call-type">{{ call.type }}</span>
|
||||
<span class="call-duration">{{ call.duration }}</span>
|
||||
<span class="call-time">{{ call.time }}</span>
|
||||
</div>
|
||||
<div class="call-summary">{{ call.summary }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
// 当前激活的tab
|
||||
const activeTab = ref('chat')
|
||||
|
||||
// 表单字段数据
|
||||
const formFields = computed(() => {
|
||||
const contact = props.selectedContact
|
||||
if (!contact || !contact.details) {
|
||||
return [
|
||||
{ label: '姓名', value: '暂无数据' },
|
||||
{ label: '联系方式', value: '暂无数据' },
|
||||
{ label: '意向课程', value: '暂无数据' },
|
||||
{ label: '预算范围', value: '暂无数据' }
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: '客户姓名', value: contact.name || '暂无' },
|
||||
{ label: '孩子姓名', value: contact.details.childName || '暂无' },
|
||||
{ label: '孩子年龄', value: contact.details.childAge ? `${contact.details.childAge}岁` : '暂无' },
|
||||
{ label: '关注问题', value: contact.details.concerns?.join('、') || '暂无' },
|
||||
{ label: '预算范围', value: contact.details.budget || '暂无' },
|
||||
{ label: '偏好时间', value: contact.details.preferredTime || '暂无' },
|
||||
{ label: '销售阶段', value: contact.salesStage || '暂无' },
|
||||
{ label: '健康度', value: contact.health ? `${contact.health}%` : '暂无' }
|
||||
]
|
||||
})
|
||||
|
||||
// 聊天数据
|
||||
const chatData = computed(() => ({
|
||||
count: props.selectedContact?.chatCount || 127,
|
||||
lastMessage: props.selectedContact?.lastMessage || '1小时前'
|
||||
}))
|
||||
|
||||
// 通话数据
|
||||
const callData = computed(() => ({
|
||||
count: props.selectedContact?.callCount || 5,
|
||||
totalDuration: props.selectedContact?.totalCallDuration || '45分钟'
|
||||
}))
|
||||
|
||||
// 聊天消息列表
|
||||
const chatMessages = computed(() => {
|
||||
return [
|
||||
{
|
||||
sender: '客户',
|
||||
time: '今天 14:30',
|
||||
content: '你好,我想了解一下数学课程的具体安排和费用情况。'
|
||||
},
|
||||
{
|
||||
sender: '我',
|
||||
time: '今天 14:32',
|
||||
content: '您好!我们的数学课程分为基础班和提高班,根据孩子的年龄和基础来安排。费用方面,基础班是6000元/期,提高班是8000元/期。'
|
||||
},
|
||||
{
|
||||
sender: '客户',
|
||||
time: '今天 14:35',
|
||||
content: '孩子现在8岁,数学基础一般,应该选择哪个班级比较合适?'
|
||||
},
|
||||
{
|
||||
sender: '我',
|
||||
time: '今天 14:37',
|
||||
content: '建议先从基础班开始,我们会有专业的测评来确定孩子的具体水平,然后制定个性化的学习方案。'
|
||||
},
|
||||
{
|
||||
sender: '客户',
|
||||
time: '今天 15:20',
|
||||
content: '好的,那什么时候可以安排试听课呢?'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 通话记录列表
|
||||
const callRecords = computed(() => {
|
||||
return [
|
||||
{
|
||||
type: '呼出',
|
||||
duration: '12分钟',
|
||||
time: '今天 10:30',
|
||||
summary: '初次沟通,了解客户基本需求。客户对数学课程比较感兴趣,孩子8岁,希望提高数学成绩。约定发送详细资料。'
|
||||
},
|
||||
{
|
||||
type: '呼入',
|
||||
duration: '8分钟',
|
||||
time: '昨天 16:45',
|
||||
summary: '客户主动来电咨询价格和上课时间。解答了关于师资力量和教学方法的问题。客户表示需要和家人商量。'
|
||||
},
|
||||
{
|
||||
type: '呼出',
|
||||
duration: '15分钟',
|
||||
time: '3天前 14:20',
|
||||
summary: '跟进客户需求,详细介绍了课程体系和教学理念。客户对一对一辅导很感兴趣,但对价格有些犹豫。'
|
||||
},
|
||||
{
|
||||
type: '呼出',
|
||||
duration: '6分钟',
|
||||
time: '5天前 11:15',
|
||||
summary: '首次电话联系,简单介绍了公司和课程概况。客户表示有兴趣,约定后续详细沟通。'
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.raw-data-cards {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.cards-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.data-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
}
|
||||
|
||||
&.form-card::before {
|
||||
background: linear-gradient(90deg, #10b981, #059669);
|
||||
}
|
||||
|
||||
&.communication-card::before {
|
||||
background: linear-gradient(90deg, #3b82f6, #1d4ed8);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
|
||||
.form-card & {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.communication-card & {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// 表单字段样式
|
||||
.form-data-list {
|
||||
.form-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab 容器样式
|
||||
.tab-container {
|
||||
.tab-buttons {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
min-height: 300px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
|
||||
.content-count {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.content-time {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天消息样式
|
||||
.message-list {
|
||||
.message-item {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.message-sender {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通话记录样式
|
||||
.call-list {
|
||||
.call-item {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
border-left: 4px solid #3b82f6;
|
||||
|
||||
.call-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.call-type {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: #dbeafe;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.call-duration {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.call-time {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.call-summary {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-action {
|
||||
border-top: 1px solid #f3f4f6;
|
||||
padding-top: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover svg {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.raw-data-cards {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.cards-container {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
318
my-vue-app/src/views/person/components/SalesTimeline.vue
Normal file
318
my-vue-app/src/views/person/components/SalesTimeline.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div class="sales-timeline">
|
||||
<div class="timeline-container">
|
||||
<div class="timeline-line"></div>
|
||||
<div
|
||||
v-for="(stage, index) in stages"
|
||||
:key="stage.id"
|
||||
class="timeline-stage"
|
||||
:class="{ 'active': stage.count > 0, 'selected': selectedStage === stage.name }"
|
||||
@click="selectStage(stage.name)"
|
||||
>
|
||||
<div class="stage-marker">
|
||||
<div class="marker-circle">
|
||||
<span class="stage-number">{{ index + 1 }}</span>
|
||||
</div>
|
||||
<div class="marker-line" v-if="index < stages.length - 1"></div>
|
||||
</div>
|
||||
<div class="stage-content">
|
||||
<h3 class="stage-title">{{ stage.displayName || stage.name }}</h3>
|
||||
<div class="stage-stats">
|
||||
<span class="stage-count">{{ stage.count }}</span>
|
||||
<span class="stage-label">位客户</span>
|
||||
</div>
|
||||
<div class="stage-percentage">{{ getPercentage(stage.count) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
// 定义props
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
selectedStage: {
|
||||
type: String,
|
||||
default: 'all'
|
||||
}
|
||||
});
|
||||
|
||||
// 定义emits
|
||||
const emit = defineEmits(['stage-select']);
|
||||
|
||||
// 计算总客户数
|
||||
const totalCustomers = computed(() => {
|
||||
const baseStages = [
|
||||
props.data.newData || 120,
|
||||
props.data.addedWechat || 85,
|
||||
props.data.filledForm || 65,
|
||||
props.data.phoneCall || 45,
|
||||
props.data.lessons || 32,
|
||||
props.data.deposit || 25,
|
||||
props.data.followUp || 18,
|
||||
props.data.closed || 12
|
||||
];
|
||||
return Math.max(...baseStages);
|
||||
});
|
||||
|
||||
// 销售阶段数据
|
||||
const stages = computed(() => [
|
||||
{ id: 0, name: 'all', displayName: '全部', count: totalCustomers.value, color: '#f3f4f6' },
|
||||
{ id: 1, name: '新数据', displayName: '新数据', count: props.data.newData || 120, color: '#e3f2fd' },
|
||||
{ id: 2, name: '已加微', displayName: '已加微', count: props.data.addedWechat || 85, color: '#bbdefb' },
|
||||
{ id: 3, name: '已填表单', displayName: '已填表单', count: props.data.filledForm || 65, color: '#90caf9' },
|
||||
{ id: 4, name: '20分钟通话', displayName: '20分钟通话', count: props.data.phoneCall || 45, color: '#64b5f6' },
|
||||
{ id: 5, name: '课1-4', displayName: '课1-4', count: props.data.lessons || 32, color: '#42a5f5' },
|
||||
{ id: 6, name: '付定金', displayName: '付定金', count: props.data.deposit || 25, color: '#2196f3' },
|
||||
{ id: 7, name: '催单', displayName: '催单', count: props.data.followUp || 18, color: '#1e88e5' },
|
||||
{ id: 8, name: '成交', displayName: '成交', count: props.data.closed || 12, color: '#1976d2' }
|
||||
]);
|
||||
|
||||
// 计算百分比
|
||||
const getPercentage = (count) => {
|
||||
if (totalCustomers.value === 0) return 0;
|
||||
return Math.round((count / totalCustomers.value) * 100);
|
||||
};
|
||||
|
||||
// 选择阶段
|
||||
const selectStage = (stageName) => {
|
||||
emit('stage-select', stageName);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Color Palette
|
||||
$primary: #3b82f6;
|
||||
$success: #22c55e;
|
||||
$warning: #f59e0b;
|
||||
$danger: #ef4444;
|
||||
$slate-100: #f1f5f9;
|
||||
$slate-200: #e2e8f0;
|
||||
$slate-300: #cbd5e1;
|
||||
$slate-400: #94a3b8;
|
||||
$slate-500: #64748b;
|
||||
$slate-600: #475569;
|
||||
$slate-700: #334155;
|
||||
$slate-800: #1e293b;
|
||||
$white: #ffffff;
|
||||
|
||||
.sales-timeline {
|
||||
padding: 1.5rem;
|
||||
background: $white;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
overflow-x: auto;
|
||||
padding: 2rem 1rem 1rem 1rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 1.5rem 0.5rem 0.5rem 0.5rem;
|
||||
-webkit-overflow-scrolling: touch; /* 改善iOS滚动体验 */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: 1rem 0.25rem 0.25rem 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-line {
|
||||
position: absolute;
|
||||
top: 4rem;
|
||||
left: 2rem;
|
||||
right: 2rem;
|
||||
height: 3px;
|
||||
background: linear-gradient(to right, $primary, $success);
|
||||
border-radius: 2px;
|
||||
// z-index: 1;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
top: 3rem;
|
||||
left: 1.5rem;
|
||||
right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-stage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 8px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
gap: 0.75rem;
|
||||
min-width: 120px; /* 确保每个阶段有最小宽度 */
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
gap: 0.5rem;
|
||||
min-width: 100px;
|
||||
padding: 0.25rem 4px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
transform: translateY(-2px);
|
||||
|
||||
.marker-circle {
|
||||
border-color: $primary;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
|
||||
|
||||
.marker-circle {
|
||||
border-color: $primary;
|
||||
background: $primary;
|
||||
transform: scale(1.2);
|
||||
|
||||
.stage-number {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
.marker-circle {
|
||||
background: linear-gradient(135deg, $primary, $success);
|
||||
color: $white;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.stage-content {
|
||||
.stage-title {
|
||||
color: $slate-800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stage-count {
|
||||
color: $primary;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stage-marker {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
|
||||
.marker-circle {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 50%;
|
||||
background: $slate-200;
|
||||
border: 3px solid $white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.stage-number {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: $slate-600;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stage-content {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
.stage-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
color: $slate-700;
|
||||
margin: 0 0 0.75rem 0;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-stats {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.stage-count {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: $slate-600;
|
||||
line-height: 1;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-label {
|
||||
font-size: 0.875rem;
|
||||
color: $slate-500;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-percentage {
|
||||
font-size: 0.875rem;
|
||||
color: $slate-400;
|
||||
font-weight: 500;
|
||||
background: $slate-100;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
</style>
|
||||
1049
my-vue-app/src/views/person/components/SalesTimelineWithTaskList.vue
Normal file
1049
my-vue-app/src/views/person/components/SalesTimelineWithTaskList.vue
Normal file
File diff suppressed because it is too large
Load Diff
207
my-vue-app/src/views/person/components/StatisticData.vue
Normal file
207
my-vue-app/src/views/person/components/StatisticData.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="stat-card kpi-card">
|
||||
<h3 class="card-title">统计指标</h3>
|
||||
<div class="kpi-grid stats-grid-inner">
|
||||
<div class="kpi-item stat-item">
|
||||
<div class="stat-icon customer-rate">
|
||||
<i class="el-icon-chat-dot-round"></i>
|
||||
</div>
|
||||
<div class="kpi-value">{{ customerCommunicationRate }}</div>
|
||||
<p>活跃客户沟通率</p>
|
||||
</div>
|
||||
<div class="kpi-item stat-item">
|
||||
<div class="stat-icon response-time">
|
||||
<i class="el-icon-timer"></i>
|
||||
</div>
|
||||
<div class="kpi-value">{{ averageResponseTime }}<span class="kpi-unit">分钟</span></div>
|
||||
<p>平均应答时间</p>
|
||||
</div>
|
||||
<div class="kpi-item stat-item">
|
||||
<div class="stat-icon timeout-rate">
|
||||
<i class="el-icon-warning"></i>
|
||||
</div>
|
||||
<div class="kpi-value">{{ timeoutResponseRate }}</div>
|
||||
<p>超时应答率</p>
|
||||
</div>
|
||||
<div class="kpi-item stat-item">
|
||||
<div class="stat-icon severe-timeout-rate">
|
||||
<i class="el-icon-warning-outline"></i>
|
||||
</div>
|
||||
<div class="kpi-value">{{ severeTimeoutRate }}</div>
|
||||
<p>严重超时应答率</p>
|
||||
</div>
|
||||
<div class="kpi-item stat-item">
|
||||
<div class="stat-icon form-rate">
|
||||
<i class="el-icon-document"></i>
|
||||
</div>
|
||||
<div class="kpi-value">{{ formCompletionRate }}</div>
|
||||
<p>表格填写率</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
defineProps({
|
||||
customerCommunicationRate: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
averageResponseTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
timeoutResponseRate: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
severeTimeoutRate: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
formCompletionRate: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// --- 颜色和变量定义 ---
|
||||
$slate-50: #f8fafc;
|
||||
$slate-100: #f1f5f9;
|
||||
$slate-200: #e2e8f0;
|
||||
$slate-700: #334155;
|
||||
$slate-800: #1e293b;
|
||||
$slate-900: #303133;
|
||||
$gray-400: #909399;
|
||||
$gray-600: #606266;
|
||||
$white: #ffffff;
|
||||
|
||||
.stat-card {
|
||||
background: $white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: $slate-900;
|
||||
margin: -10px 0 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
// 统计指标卡片特定样式
|
||||
.stats-grid-inner {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
padding: 1rem 0.5rem;
|
||||
text-align: center;
|
||||
background-color: $slate-50;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid $slate-200;
|
||||
|
||||
.stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: $white;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&.customer-rate { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
&.response-time { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
|
||||
&.timeout-rate { background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); }
|
||||
&.form-rate { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); }
|
||||
&.severe-timeout-rate { background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%); }
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: $slate-800;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.kpi-unit {
|
||||
font-size: 0.875rem;
|
||||
font-weight: normal;
|
||||
color: $gray-400;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.875rem;
|
||||
color: $gray-600;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 响应式设计 ---
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid-inner {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
min-height: 100px;
|
||||
padding: 0.75rem 0.25rem;
|
||||
|
||||
.stat-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 16px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕优化 */
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid-inner {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
min-height: 80px;
|
||||
padding: 1rem;
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
|
||||
.stat-icon {
|
||||
margin-bottom: 0;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1885
my-vue-app/src/views/person/sale.vue
Normal file
1885
my-vue-app/src/views/person/sale.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user