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

添加了Vue3项目基础结构,包括路由、状态管理和API配置
实现了销售管理系统的核心功能模块,包括业绩看板、团队管理和客户分析
集成了Element Plus UI组件库和ECharts数据可视化
添加了全局样式和响应式布局支持
This commit is contained in:
2025-08-06 20:20:35 +08:00
commit 9af92a7975
66 changed files with 29082 additions and 0 deletions

24
my-vue-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
my-vue-app/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
my-vue-app/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

13
my-vue-app/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

43
my-vue-app/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "my-vue-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.10.0",
"chart.js": "^4.5.0",
"dompurify": "^3.2.6",
"echarts": "^5.6.0",
"element-plus": "^2.10.4",
"markdown-it": "^14.1.0",
"marked": "^16.1.1",
"pinia": "^3.0.2",
"vue": "^3.5.17",
"vue-chartjs": "^5.3.2",
"vue-echarts": "^7.0.3",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.1",
"@types/node": "^22.14.0",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.5.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.22.0",
"eslint-plugin-vue": "~10.0.0",
"npm-run-all2": "^7.0.2",
"prettier": "3.5.3",
"sass": "^1.89.2",
"typescript": "~5.8.0",
"vite": "^7.0.0",
"vite-plugin-vue-devtools": "^7.7.2",
"vue-tsc": "^2.2.8"
}
}

3870
my-vue-app/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>电销分析与行动看板</title>
<!-- 字体和CDN链接可以保留在这里或者通过其他方式引入 -->
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
</head>
<body class="text-gray-800">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

Binary file not shown.

15
my-vue-app/src/App.vue Normal file
View File

@@ -0,0 +1,15 @@
<script setup>
</script>
<template>
<div class="app-wrapper">
<router-view />
</div>
</template>
<style lang="scss" scoped>
.app-wrapper {
min-height: 100vh;
}
</style>

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

View File

@@ -0,0 +1,114 @@
// --- 1. Design Tokens (Variables) ---
// Colors
$color-background: #FDFBF7;
$color-text-primary: #1f2937; // gray-800
$color-text-secondary: #6b7280; // gray-500
$color-white: #ffffff;
$color-blue-primary: #4A90E2;
$color-blue-500: #3b82f6;
$color-teal-600: #0d9488;
$color-orange-600: #ea580c;
// Lead Priority Colors
$color-lead-hot: #D9534F;
$color-lead-warm: #F0AD4E;
$color-lead-cool: #5BC0DE;
// Shadows
$shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
$shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
$shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
$shadow-selected-card: 0 4px 12px rgba(0,0,0,0.1);
// Spacing (1 unit = 0.25rem)
$spacing-1: 0.25rem;
$spacing-2: 0.5rem;
$spacing-3: 0.75rem;
$spacing-4: 1rem;
$spacing-5: 1.25rem;
$spacing-6: 1.5rem;
$spacing-8: 2rem;
$spacing-16: 4rem;
// Breakpoints - 重新设计适配移动端和web端
$breakpoint-sm: 640px; // 小屏手机
$breakpoint-md: 768px; // 大屏手机/小平板
$breakpoint-lg: 1024px; // 平板/小桌面
$breakpoint-xl: 1280px; // 桌面
$breakpoint-2xl: 1536px; // 大屏桌面
// --- 2. Global Styles & Resets ---
body {
font-family: 'Inter', 'Noto Sans SC', sans-serif;
background-color: $color-background;
color: $color-text-primary;
}
.container {
width: 100%;
margin-left: auto;
margin-right: auto;
padding-left: $spacing-4;
padding-right: $spacing-4;
@media (min-width: $breakpoint-md) {
padding-left: $spacing-6;
padding-right: $spacing-6;
}
@media (min-width: 1400px) { // Standard container max-widths
max-width: 1280px;
}
}
// --- 3. Global Component Styles ---
// Chart container
.chart-container {
position: relative;
width: 100%;
height: 250px;
// max-height: 250px;
@media (min-width: $breakpoint-md) {
height: 300px;
// max-height: 300px;
}
}
// Lead card styles used in CommandCenter
.lead-card {
transition: all 0.2s ease-in-out;
border-left-width: 4px;
cursor: pointer;
background-color: $color-white;
padding: 0.75rem; // p-3
border-radius: 0.5rem; // rounded-lg
box-shadow: $shadow-sm;
&:hover {
box-shadow: $shadow-md;
}
&.selected {
transform: translateY(-2px);
box-shadow: $shadow-selected-card;
border-left-color: $color-blue-primary;
}
&.hot-lead { border-color: $color-lead-hot; }
&.warm-lead { border-color: $color-lead-warm; }
&.cool-lead { border-color: $color-lead-cool; }
}
// Task overdue animation
.task-overdue {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}

View File

@@ -0,0 +1,221 @@
<template>
<div class="header-container">
<!-- 左侧Logo -->
<div class="header-left">
<img
src="/src/assets/states/yclogo.png"
alt="暖洋葱家庭教育"
class="logo"
@error="handleImageError"
/>
</div>
<!-- 右侧用户信息 -->
<div class="header-ringht" style="display: flex; align-items: center; gap: 10px;">
<img
src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png"
alt="用户头像"
class="avatar"
@error="handleAvatarError"
style="width: 35px; height: 35px;"
/>
<span>你好管理员</span>
</div>
</div>
</template>
<script>
</script>
<style scoped>
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
width: 45vw;
height: 60px;
padding: 0 20px;
background: #ffffff;
border-bottom: 1px solid #e4e7ed;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: background-color 0.3s ease;
margin-bottom: 10px;
}
.header-left {
display: flex;
align-items: center;
}
.logo {
height: 40px;
width: auto;
max-width: 200px;
object-fit: contain;
cursor: pointer;
transition: opacity 0.3s ease;
}
.logo:hover {
opacity: 0.8;
}
.header-right {
display: flex;
align-items: center;
justify-content: flex-end;
}
.avatar-container {
display: flex;
align-items: center;
padding: 5px 10px;
border-radius: 16px;
cursor: pointer;
transition: all 0.3s ease;
background: #f5f7fa;
}
.avatar-container:hover {
background: #e4e7ed;
transform: translateY(-1px);
}
.el-avatar {
margin-right: 8px;
border: 2px solid #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.user-info {
display: flex;
flex-direction: column;
margin-right: 5px;
}
.status-indicator {
display: flex;
align-items: center;
font-size: 11px;
color: #67c23a;
}
.status-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background-color: #67c23a;
margin-right: 3px;
display: inline-block;
}
.username {
font-size: 13px;
font-weight: 500;
color: #303133;
margin-right: 4px;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-icon {
font-size: 11px;
color: #909399;
transition: transform 0.3s ease;
margin-left: 2px;
}
.avatar-container:hover .dropdown-icon {
transform: rotate(180deg);
}
/* 下拉菜单样式 */
:deep(.el-dropdown-menu) {
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid #e4e7ed;
padding: 8px 0;
}
:deep(.el-dropdown-menu__item) {
padding: 10px 16px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease;
}
:deep(.el-dropdown-menu__item:hover) {
background: #f5f7fa;
color: #409eff;
}
:deep(.el-dropdown-menu__item.is-divided) {
border-top: 1px solid #e4e7ed;
margin-top: 4px;
padding-top: 12px;
}
:deep(.el-dropdown-menu__item .el-icon) {
font-size: 16px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.header-container {
padding: 0 15px;
}
.logo {
height: 35px;
max-width: 150px;
}
.username {
display: none;
}
.user-avatar-container {
padding: 6px 8px;
}
}
@media (max-width: 480px) {
.header-container {
padding: 0 10px;
}
.logo {
height: 30px;
max-width: 120px;
}
}
/* 暗色主题支持 */
@media (prefers-color-scheme: dark) {
.header-container {
background: #1f2937;
border-bottom-color: #374151;
}
.user-avatar-container {
background: #374151;
}
.user-avatar-container:hover {
background: #4b5563;
}
.username {
color: #f9fafb;
}
.dropdown-icon {
color: #9ca3af;
}
}
</style>

14
my-vue-app/src/main.js Normal file
View File

@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/styles/main.scss' // 导入全局 SCSS 文件
import ElementPlus from 'element-plus'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.mount('#app')

View File

@@ -0,0 +1,52 @@
import { createRouter, createWebHistory } from 'vue-router'
import Sale from '@/views/person/sale.vue'
import Manager from '@/views/maneger/manager.vue'
import SeniorManager from '@/views/senorManger/seniorManager.vue'
import TopOne from '@/views/topOne/topone.vue'
import Login from '@/views/login/login.vue'
import SecondTop from '@/views/secondTop/secondTop.vue'
const routes = [
{
path: '/',
name: 'Home',
redirect: '/sale'
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/sale',
name: 'Sale',
component: Sale
},
{
path: '/manager',
name: 'Manager',
component: Manager
},
{
path: '/senior-manager',
name: 'SeniorManager',
component: SeniorManager
},
{
path: '/second-top',
name: 'SecondTop',
component: SecondTop
},
{
path: '/top',
name: 'Top',
component: TopOne
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

View File

@@ -0,0 +1,66 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// 状态
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref(null)
const isLoggedIn = computed(() => !!token.value)
// 设置token
const setToken = (newToken) => {
token.value = newToken
if (newToken) {
localStorage.setItem('token', newToken)
} else {
localStorage.removeItem('token')
}
}
// 设置用户信息
const setUserInfo = (info) => {
userInfo.value = info
}
// 登录
const login = (tokenValue, userInfoValue = null) => {
setToken(tokenValue)
if (userInfoValue) {
setUserInfo(userInfoValue)
}
}
// 登出
const logout = () => {
setToken('')
setUserInfo(null)
localStorage.removeItem('token')
sessionStorage.removeItem('token')
}
// 清除所有数据
const clearAll = () => {
logout()
}
return {
// 状态
token,
userInfo,
isLoggedIn,
// 方法
setToken,
setUserInfo,
login,
logout,
clearAll
}
}, {
// 持久化配置(可选)
persist: {
key: 'user-store',
storage: localStorage,
paths: ['token', 'userInfo']
}
})

79
my-vue-app/src/style.css Normal file
View File

@@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,108 @@
# 销售总监战略驾驶舱 - 第一层:行政摘要
**核心目标:** 让总监在30秒内从万米高空俯瞰整个销售组织的健康状况。
**页面刷新频率:** 默认每5分钟自动刷新提供手动刷新按钮。
**全局筛选器:** 页面右上角提供一个全局日期范围筛选器,默认为“本季度”,可选择“本月”、“本年”或自定义日期范围。所有模块数据随之联动。
---
## 页面布局:顶部-中部-右侧
### 顶部区域:核心财务与销售预测
此区域横跨页面顶部,分为左右两部分,提供最关键的财务、目标和预测指标。
#### 组件1总销售额
- **标题:** 总销售额 (元)
- **可视化类型:** 巨大数字计分卡
- **核心指标:** `SUM(合同成交金额)`。根据全局筛选器(默认本季)计算。
- **设计细节:**
- 主数字以巨大字体突出显示,例如 “¥1,234,567”。
- 主数字下方显示环比/同比数据,例如:“较上期 ▲15.5%”。增长为绿色,下降为红色。
- 鼠标悬停在环比/同比数据上时,提示信息框显示:“*上期销售额¥1,068,889*”。
- **交互与下钻路径:**
- **点击主数字或标题**,下钻到 **【第二层:销售业绩诊断看板】**。同时,将当前全局筛选器的时间范围作为筛选条件传递给第二层看板,以便进行深度业绩分析。
#### 组件2销售目标完成率
- **标题:** 销售目标完成率
- **可视化类型:** 带目标的仪表盘/进度条
- **核心指标:** `(SUM(本期已完成销售额) / SUM(本期销售目标)) * 100%`
- **设计细节:**
- 使用一个圆形仪表盘。中心大号字体显示完成率百分比例如“82%”。
- 仪表盘颜色根据完成率动态变化:<80%为警告橙色80-100%为蓝色,>100%为成就绿色。
- 鼠标悬停在图表上时,提示信息框显示:“*实际销售额¥1,234,567 / 目标销售额¥1,500,000*”。
- **交互与下钻路径:**
- **点击图表**,下钻到 **【第二层:销售团队业绩排行榜】**。此页面将以排行榜形式展示各个销售团队及关键销售人员的目标完成情况,并默认按“目标完成率”降序排列。
#### 组件3加权销售预测
- **标题:** 加权销售预测 (本季)
- **可视化类型:** 巨大数字计分卡
- **核心指标:** `SUM(商机金额 * 成交概率)`,计算所有处于“进行中”状态的商机。
- **设计细节:**
- 主数字巨大字体显示例如“¥2,500,000”。
- 下方一行小字显示与季度目标的差距:“*距离目标尚有 ¥500,000*”。
- **交互与下钻路径:**
- **点击主数字或标题**,下钻到 **【第二层:销售预测与商机管道详情页】**。该页面将列出构成此预测的所有商机,并提供按销售阶段、预计成交日期、商机负责人等维度进行筛选和排序的功能。
#### 组件4管道覆盖率
- **标题:** 管道覆盖率
- **可视化类型:** 巨大数字计分卡
- **核心指标:** `SUM(所有未关闭商机金额) / (季度销售目标 - 季度已完成销售额)`
- **设计细节:**
- 以倍数形式展示例如“3.5x”。
- 数字颜色根据行业标准动态变化(例如:<3x为红色3x-4x为黄色>4x为绿色
- 鼠标悬停时,提示信息框解释其计算公式和当前数值:“*管道健康度指标。计算方式:当前管道总金额 / (目标 - 已完成)。当前¥4,088,889 / (¥1,500,000 - ¥1,234,567) = 3.5x*”。
- **交互与下钻路径:**
- **点击数字**,同样下钻到 **【第二层:销售预测与商机管道详情页】**,但视图会聚焦于管道的整体健康状况,例如按阶段划分的商机金额分布图。
---
### 中部核心区域:整体销售漏斗健康度
此区域占据页面中心最显眼的位置,展示从线索到成交的全流程转化情况。
#### 组件5整体销售漏斗
- **标题:** 整体销售漏斗 (本季)
- **可视化类型:** 阶梯式条形漏斗图
- **核心指标:**
- `线索生成数` (来自短视频平台的总咨询量)
- `微信添加数` (成功添加微信的潜在客户数)
- `直播课参与数` (引导参加了直播课的客户数)
- `成交客户数` (最终购买课程的客户数)
- 以及各阶段之间的转化率。
- **设计细节:**
- 每个阶段用一个水平条形表示,条形内部显示该阶段的绝对数量。
- 阶段之间用箭头连接箭头上标注从上一阶段到此阶段的转化率例如“30%”)。
- 鼠标悬停在任何一个条形或箭头上,都会高亮显示该阶段,并出现提示框,提供更详细信息,如:“*阶段微信添加数数量1,500从线索到此阶段总转化率15%*”。
- **交互与下钻路径:**
- **点击漏斗的任何一个阶段条形**(例如点击“直播课参与数”),将下钻到 **【第二层:销售漏斗阶段转化分析看板】**,并将“直播课参与”作为默认筛选条件。该看板会深度分析此阶段的转化趋势、影响因素、以及不同销售团队在此阶段的表现。
---
### 右侧区域:战略性客户与成本指标
此区域位于页面右侧,提供关于客户质量和获客效率的战略性指标。
#### 组件6销售额增长趋势
- **标题:** 销售额增长趋势
- **可视化类型:** 组合图 (折线图+条形图)
- **核心指标:** `每月/每季度销售额``环比/同比增长率`
- **设计细节:**
- 默认显示过去6个月的趋势。条形图代表每月的销售额折线图代表月度环比增长率。
- 提供一个切换按钮,可将视图从“月度(MoM)”切换为“季度(YoY)”。
- 鼠标悬停在某个月的条形上时,提示框同时显示该月的“销售额”和“环比增长率”。
- **交互与下钻路径:**
- **点击某一个条形**,下钻到 **【第二层:销售业绩诊断看板】**,并将筛选条件自动设为被点击的那个月份/季度。
#### 组件7CLV / CAC 比率
- **标题:** 客户终身价值 vs. 客户获取成本 (CLV/CAC)
- **可视化类型:** 巨大数字计分卡
- **核心指标:** `平均CLV / 平均CAC`
- **设计细节:**
- 以比率形式展示例如“4.2 : 1”。
- 数字旁边可附带一个微型趋势图Sparkline展示该比率在过去12个月的变化趋势。
- 鼠标悬停时,提示框显示:“*CLV: ¥8,400, CAC: ¥2,000。该比率反映了客户价值与获客成本的关系理想状态应大于3。*”
- **交互与下钻路径:**
- **点击该组件**,下钻到 **【第二层:客户价值与成本分析看板】**。该看板将详细拆解CAC的构成如各渠道广告费、销售人力成本等和CLV的计算模型并允许按客户来源渠道、首次购买课程等维度进行细分对比分析。

View File

@@ -0,0 +1,168 @@
import axios from 'axios';
// Dify API的基础URL可以根据你的实际部署地址进行修改
const BaseUrl = 'https://ai.nycjy1.cn/v1'; // 示例: 替换成你的 Dify API 地址
/**
* 处理从Dify API返回的流式数据块。
* @param chunk - 从服务器接收到的数据块字符串。
* @param options - 包含当前状态和回调函数的对象。
* @returns 更新后的状态对象。
*/
function handleStreamResponse(chunk, options) {
try {
// 忽略空的数据块
if (!chunk.trim()) {
return options;
}
// 流式数据可能包含多个 "data: {...}" 块,它们由两个换行符分隔
const lines = chunk.split('\n\n');
for (const line of lines) {
// 跳过无效行或非数据行
if (!line.trim() || !line.startsWith('data:')) continue;
// 提取 "data:" 后面的JSON字符串
const jsonStr = line.slice(5).trim();
if (!jsonStr) continue;
try {
// 确保是完整的JSON对象
if (!jsonStr.startsWith('{') || !jsonStr.endsWith('}')) {
console.warn('接收到不完整的JSON对象:', jsonStr);
continue;
}
const data = JSON.parse(jsonStr);
// 事件类型为 'message'表示正在接收AI的回答
if (data.event === 'message') {
const newContent = data.answer || '';
options.fullResponse += newContent; // 将新的内容追加到完整响应中
// 调用实时更新回调函数
options.onMessageUpdate({
content: options.fullResponse,
isStreaming: true
});
// 事件类型为 'message_end',表示消息流结束
} else if (data.event === 'message_end') {
options.isStreamEnded = true;
// 调用最终更新回调函数
options.onMessageUpdate({
content: options.fullResponse,
isStreaming: false
});
// 调用流结束的回调
options.onStreamEnd();
}
} catch (parseError) {
console.warn('JSON解析错误:', {
error: parseError,
rawData: jsonStr,
});
continue;
}
}
} catch (e) {
console.error('流处理时发生错误:', e);
}
return options;
}
/**
* 一个简单的Dify聊天服务类。
*/
export class SimpleChatService {
constructor(apiKey, baseUrl = BaseUrl) {
if (!apiKey) {
throw new Error("API Key是必须的。");
}
this.apiKey = apiKey;
this.baseUrl = baseUrl;
}
/**
* 发送消息到Dify API并处理流式响应。
* @param userMessage - 用户发送的消息。
* @param onMessageUpdate - 当收到新数据块时调用的回调函数。
* @param onStreamEnd - 当流结束时调用的回调函数。
* @param abortSignal - 用于取消请求的AbortSignal。
*/
async sendMessage(
userMessage,
onMessageUpdate,
onStreamEnd,
abortSignal = null
) {
const params = {
inputs: {},
query: userMessage,
response_mode: 'streaming', // 声明需要流式响应
conversation_id: '', // 如果需要继续之前的对话请传入对应的ID
user: 'web-user-axios-simple',
auto_generate_name: true
};
let state = {
fullResponse: '',
isStreamEnded: false,
};
// 用于跟踪已处理的响应文本长度
let processedLength = 0;
try {
await axios({
method: 'POST',
url: `${this.baseUrl}/chat-messages`,
data: params,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
responseType: 'text', // 必须设置为 'text' 以便处理流式响应
signal: abortSignal, // 关联取消信号
onDownloadProgress: (progressEvent) => {
// 获取自上次回调以来新增的文本部分
const newChunk = progressEvent.event.target.responseText.substring(processedLength);
processedLength = progressEvent.event.target.responseText.length;
// 将新的数据块传递给处理函数
state = handleStreamResponse(newChunk, {
...state,
onMessageUpdate,
onStreamEnd,
});
}
});
// 检查请求完成后,流是否已正常结束
if (!state.isStreamEnded) {
console.warn("请求已完成,但未收到 'message_end' 事件。");
onMessageUpdate({
content: state.fullResponse,
isStreaming: false
});
onStreamEnd();
}
} catch (error) {
if (axios.isCancel(error)) {
console.log('请求已被用户取消。');
} else {
console.error('发送消息失败:', error);
onMessageUpdate({
content: '抱歉发送消息失败请检查API Key或网络连接后重试。',
isStreaming: false
});
}
// 确保在出错或取消时也调用 onStreamEnd
onStreamEnd();
}
}
}

View File

@@ -0,0 +1,227 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'
// 创建axios实例
const service = axios.create({
baseURL: 'http://192.168.15.56:8889' || '', // API基础路径支持完整URL
timeout: 15000, // 请求超时时间
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
})
// 请求拦截器
service.interceptors.request.use(
config => {
// 在发送请求之前做些什么
console.log('发送请求:', config)
// 添加token到请求头
const userStore = useUserStore()
const token = userStore.token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 添加时间戳防止缓存
if (config.method === 'get') {
config.params = {
...config.params,
_t: Date.now()
}
}
// 显示加载状态
if (config.showLoading !== false) {
// 可以在这里添加全局loading
console.log('显示加载中...')
}
return config
},
error => {
// 对请求错误做些什么
console.error('请求错误:', error)
ElMessage.error('请求发送失败')
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
response => {
// 隐藏加载状态
console.log('隐藏加载中...')
// 对响应数据做点什么
console.log('收到响应:', response)
const { data, status } = response
// HTTP状态码检查
if (status === 200) {
// 根据后端返回的数据结构进行判断
if (data.code === 200 || data.success === true) {
// 请求成功
return data
} else if (data.code === 401) {
// token过期或无效
ElMessage.error('登录已过期,请重新登录')
const userStore = useUserStore()
userStore.logout()
router.push('/login')
return Promise.reject(new Error('登录已过期'))
} else if (data.code === 403) {
// 权限不足
ElMessage.error('权限不足,无法访问')
return Promise.reject(new Error('权限不足'))
} else {
// 其他业务错误
const errorMsg = data.message || data.msg || '请求失败'
ElMessage.error(errorMsg)
return Promise.reject(new Error(errorMsg))
}
} else {
ElMessage.error('网络请求失败')
return Promise.reject(new Error('网络请求失败'))
}
},
error => {
// 隐藏加载状态
console.log('隐藏加载中...')
// 对响应错误做点什么
console.error('响应错误:', error)
let errorMessage = '网络错误'
if (error.response) {
// 服务器返回了错误状态码
const { status, data } = error.response
switch (status) {
case 400:
errorMessage = data.message || '请求参数错误'
break
case 401:
errorMessage = '登录已过期,请重新登录'
const userStore = useUserStore()
userStore.logout()
router.push('/login')
break
case 403:
errorMessage = '权限不足,无法访问'
break
case 404:
errorMessage = '请求的资源不存在'
break
case 500:
errorMessage = '服务器内部错误'
break
case 502:
errorMessage = '网关错误'
break
case 503:
errorMessage = '服务不可用'
break
case 504:
errorMessage = '网关超时'
break
default:
errorMessage = data.message || `请求失败 (${status})`
}
} else if (error.request) {
// 请求已发出但没有收到响应
errorMessage = '网络连接超时,请检查网络'
} else {
// 其他错误
errorMessage = error.message || '请求失败'
}
ElMessage.error(errorMessage)
return Promise.reject(error)
}
)
// 封装常用的请求方法
const http = {
// GET请求
get(url, params = {}, config = {}) {
return service({
method: 'get',
url,
params,
...config
})
},
// POST请求
post(url, data = {}, config = {}) {
return service({
method: 'post',
url,
data,
...config
})
},
// PUT请求
put(url, data = {}, config = {}) {
return service({
method: 'put',
url,
data,
...config
})
},
// DELETE请求
delete(url, params = {}, config = {}) {
return service({
method: 'delete',
url,
params,
...config
})
},
// PATCH请求
patch(url, data = {}, config = {}) {
return service({
method: 'patch',
url,
data,
...config
})
},
// 文件上传
upload(url, formData, config = {}) {
return service({
method: 'post',
url,
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
},
...config
})
},
// 文件下载
download(url, params = {}, config = {}) {
return service({
method: 'get',
url,
params,
responseType: 'blob',
...config
})
}
}
// 导出axios实例和封装的方法
export default http
export { service }

View File

@@ -0,0 +1,256 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>欢迎登录</h1>
<p>请输入您的账号和密码</p>
</div>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<label for="username">账号</label>
<input
id="username"
v-model="loginForm.username"
type="text"
placeholder="请输入账号"
required
:disabled="loading"
/>
</div>
<div class="form-group">
<label for="password">密码</label>
<input
id="password"
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
required
:disabled="loading"
/>
</div>
<button type="submit" class="login-btn" :disabled="loading">
<span v-if="loading" class="loading-spinner"></span>
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import http from '@/utils/https'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
// 响应式数据
const loginForm = ref({
username: '',
password: ''
})
const loading = ref(false)
const errorMessage = ref('')
// 登录处理函数
const handleLogin = async () => {
if (!loginForm.value.username || !loginForm.value.password) {
errorMessage.value = '请输入账号和密码'
return
}
loading.value = true
errorMessage.value = ''
try {
// 调用登录API
const response = await http.post('/api/v1/login', {
username: loginForm.value.username,
password: loginForm.value.password
})
// 登录成功处理
if (response.code === 200 || response.success) {
// 使用Pinia存储用户信息和token
if (response.data && response.data.token) {
userStore.login(response.data.token, response.data.userInfo || null)
}
// 跳转到首页
router.push('/top')
} else {
errorMessage.value = response.message || '登录失败'
}
} catch (error) {
// 错误已在axios拦截器中处理这里只需要设置本地错误信息
errorMessage.value = error.message || '登录失败,请重试'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
/* background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); */
padding: 20px;
}
.login-card {
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 100%;
max-width: 400px;
animation: slideUp 0.6s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header h1 {
font-size: 28px;
font-weight: 700;
color: #1a202c;
margin: 0 0 8px 0;
}
.login-header p {
color: #718096;
font-size: 14px;
margin: 0;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 600;
color: #374151;
}
.form-group input {
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 16px;
transition: all 0.3s ease;
background: #f9fafb;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
background: white;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
padding: 14px 24px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
}
.login-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
}
.login-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top: 2px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-message {
background: #fee2e2;
color: #dc2626;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
text-align: center;
margin-top: 16px;
border: 1px solid #fecaca;
}
/* 响应式设计 */
@media (max-width: 480px) {
.login-card {
padding: 24px;
margin: 16px;
}
.login-header h1 {
font-size: 24px;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,625 @@
<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">{{ kpiData.totalCalls }}</div>
<p>今日通话</p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ kpiData.successRate }}%</div>
<p>成功率</p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ kpiData.avgDuration }}<span class="kpi-unit">min</span></div>
<p>平均时长</p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ kpiData.conversionRate }}%</div>
<p>转化率</p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ kpiData.assignedData }}</div>
<p>本期分配数据</p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ kpiData.wechatAddRate }}%</div>
<p>加微率</p>
</div>
</div>
</div>
<!-- 统计指标 -->
<StatisticalIndicators
:customerCommunicationRate="customerCommunicationRate"
:averageResponseTime="averageResponseTime"
:timeoutResponseRate="timeoutResponseRate"
:severeTimeoutRate="severeTimeoutRate"
:formCompletionRate="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 StatisticalIndicators from '@/views/senorManger/components/StatisticalIndicators.vue';
import * as echarts from 'echarts';
import Chart from 'chart.js/auto';
// Chart.js 实例
const chartInstances = {};
// DOM 元素引用
const personalFunnelChartCanvas = ref(null);
const contactTimeChartCanvas = ref(null);
// --- 响应式状态定义 ---
// 核心KPI数据
const kpiData = reactive({
totalCalls: 128,
successRate: 75,
avgDuration: 8.5,
conversionRate: 15,
assignedData: 256,
wechatAddRate: 68
});
// 问题排行榜数据
const problemData = reactive([
{ value: 180, name: '学习成绩提升' }, { value: 150, name: '学习习惯培养' }, { value: 120, name: '兴趣爱好发展' }, { value: 100, name: '心理健康问题' }, { value: 80, name: '升学规划' }, { value: 70, name: '亲子关系改善' }
]);
// Chart.js 数据
const funnelData = reactive({
labels: ['线索', '合格', '报价', '成交'],
data: [120, 90, 45, 18],
});
const contactTimeData = reactive({
labels: ['9-11', '11-13', '13-15', '15-17', '17-19', '19-21'],
data: [65, 70, 85, 80, 60, 55],
});
// --- 计算属性 ---
const sortedProblemData = computed(() => {
return [...problemData].sort((a, b) => b.value - a.value);
});
const totalProblemCount = computed(() => {
return problemData.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) => ((value / totalProblemCount.value) * 100).toFixed(1);
const getRankingClass = (index) => ({ 'rank-first': index === 0, 'rank-second': index === 1, 'rank-third': index === 2, 'rank-other': index > 2 });
const getRankBadgeClass = (index) => ({ 'badge-gold': index === 0, 'badge-silver': index === 1, 'badge-bronze': index === 2, 'badge-default': index > 2 });
// --- 生命周期钩子 ---
onMounted(() => {
renderPersonalFunnelChart();
renderContactTimeChart();
});
onBeforeUnmount(() => {
Object.values(chartInstances).forEach(chart => chart.destroy());
});
</script>
<style lang="scss" scoped>
// --- 颜色和变量定义 ---
$slate-50: #f8fafc;
$slate-100: #f1f5f9;
$slate-200: #e2e8f0;
$slate-700: #334155;
$slate-800: #1e293b;
$slate-900: #303133;
$gray-400: #909399;
$gray-600: #606266;
$blue: #409eff;
$green: #67c23a;
$orange: #e6a23c;
$red: #f56c6c;
$white: #ffffff;
.personal-dashboard {
padding: 10px;
background-color: #f5f7fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.dashboard-header {
background: $white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 24px;
h2 {
margin: 0;
color: $slate-900;
font-size: 24px;
font-weight: 600;
}
}
// --- 统计卡片网格 ---
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.stat-card {
background: $white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
font-size: 24px;
color: $white;
&.customer-rate { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
&.response-time { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
&.timeout-rate { background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); }
&.form-rate { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); }
&.severe-timeout-rate { background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%); }
}
.stat-content {
.stat-value { font-size: 20px; font-weight: 700; color: $slate-900; margin-bottom: 4px; }
.stat-label { font-size: 14px; color: $gray-400; font-weight: 500; }
}
// --- KPI 卡片特定样式 ---
.kpi-card {
display: flex;
flex-direction: column;
align-items: stretch;
.card-title {
font-size: 18px;
font-weight: 600;
color: $slate-900;
margin: -10px 0 16px;
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
}
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
// grid-template-rows: repeat(2, 1fr);
gap: 1rem;
width: 100%;
}
.kpi-item {
text-align: center;
padding: 0.75rem;
background-color: $slate-50;
border-radius: 0.5rem;
border: 1px solid $slate-200;
.kpi-value {
font-size: 1.5rem;
font-weight: bold;
color: $slate-800;
}
.kpi-unit {
font-size: 0.875rem;
font-weight: normal;
color: $gray-400;
margin-left: 2px;
}
p {
font-size: 0.875rem;
color: $gray-600;
margin: 0.25rem 0 0;
}
}
// 统计指标卡片特定样式
.stats-grid-inner {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.75rem;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 120px;
padding: 1rem 0.5rem;
.stat-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: $white;
margin-bottom: 0.75rem;
&.customer-rate { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
&.response-time { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
&.timeout-rate { background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); }
&.form-rate { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); }
&.severe-timeout-rate { background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%); }
}
.kpi-value {
margin-bottom: 0.25rem;
}
}
// --- 图表区域 ---
.charts-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
}
.chart-container {
background: $white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
min-height: 380px;
display: flex;
flex-direction: column;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 16px;
border-bottom: 1px solid #ebeef5;
h3 { margin: 0; color: $slate-900; font-size: 18px; font-weight: 600; }
}
.chart-content {
padding-left: 20px;
padding-right: 20px;
padding-bottom: 20px;
flex-grow: 1;
position: relative;
canvas {
max-height: 280px;
}
}
.chart-select {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid $slate-200;
background-color: $slate-50;
font-size: 14px;
}
// --- 排行榜样式 ---
.problem-ranking {
max-height: 320px;
overflow-y: auto;
}
.ranking-item {
display: flex;
align-items: center;
padding: 12px 0;
&:not(:last-child) { border-bottom: 1px solid #f0f2f5; }
}
.rank-number .rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-weight: bold;
font-size: 14px;
color: $white;
&.badge-gold { background: linear-gradient(135deg, #ffd700, #ffb300); }
&.badge-silver { background: linear-gradient(135deg, #c0c0c0, #a8a8a8); }
&.badge-bronze { background: linear-gradient(135deg, #cd7f32, #b8860b); }
&.badge-default { background: linear-gradient(135deg, #6c757d, #495057); }
}
.problem-info { flex: 1; margin: 0 16px; }
.problem-name { font-size: 15px; font-weight: 500; color: #212529; margin-bottom: 4px; }
.problem-count { font-size: 13px; color: #6c757d; }
.problem-percentage { min-width: 80px; text-align: right; }
.percentage { font-size: 15px; font-weight: bold; color: #495057; margin-bottom: 6px; display: block; }
.progress-bar { width: 100%; height: 6px; background: rgba(0,0,0,0.1); border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #007bff, #0056b3); border-radius: 3px; }
.rank-first .progress-fill { background: linear-gradient(90deg, #ffd700, #ffb300); }
.rank-second .progress-fill { background: linear-gradient(90deg, #c0c0c0, #a8a8a8); }
.rank-third .progress-fill { background: linear-gradient(90deg, #cd7f32, #b8860b); }
// --- 响应式设计 ---
@media (max-width: 1200px) {
.charts-section { grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); }
}
@media (max-width: 768px) {
.personal-dashboard { padding: 15px; }
.stats-grid, .charts-section { grid-template-columns: 1fr; }
.stat-card { flex-direction: row; }
.dashboard-header {
padding: 16px;
h2 {
font-size: 20px;
}
}
.kpi-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.kpi-item {
padding: 0.5rem;
.kpi-value {
font-size: 1.25rem;
}
p {
font-size: 0.75rem;
}
}
.stats-grid-inner {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.stat-item {
min-height: 100px;
padding: 0.75rem 0.25rem;
.stat-icon {
width: 32px;
height: 32px;
font-size: 16px;
margin-bottom: 0.5rem;
}
.kpi-value {
font-size: 1.125rem;
}
p {
font-size: 0.75rem;
}
}
.chart-container {
min-height: 300px;
}
.chart-header {
padding: 16px 16px 12px;
h3 {
font-size: 16px;
}
}
.chart-content {
padding-left: 16px;
padding-right: 16px;
padding-bottom: 16px;
}
}
/* 小屏幕优化 */
@media (max-width: 480px) {
.personal-dashboard {
padding: 10px;
}
.dashboard-header {
padding: 12px;
h2 {
font-size: 18px;
}
}
.kpi-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.kpi-item {
padding: 0.75rem;
.kpi-value {
font-size: 1.5rem;
}
}
.stats-grid-inner {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.stat-item {
min-height: 80px;
padding: 1rem;
flex-direction: row;
text-align: left;
.stat-icon {
margin-bottom: 0;
margin-right: 0.75rem;
}
}
.chart-container {
min-height: 250px;
}
.charts-section {
grid-template-columns: 1fr;
gap: 16px;
}
}
</style>

View File

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

View File

@@ -0,0 +1,318 @@
<template>
<div class="sales-timeline">
<div class="timeline-container">
<div class="timeline-line"></div>
<div
v-for="(stage, index) in stages"
:key="stage.id"
class="timeline-stage"
:class="{ 'active': stage.count > 0, 'selected': selectedStage === stage.name }"
@click="selectStage(stage.name)"
>
<div class="stage-marker">
<div class="marker-circle">
<span class="stage-number">{{ index + 1 }}</span>
</div>
<div class="marker-line" v-if="index < stages.length - 1"></div>
</div>
<div class="stage-content">
<h3 class="stage-title">{{ stage.displayName || stage.name }}</h3>
<div class="stage-stats">
<span class="stage-count">{{ stage.count }}</span>
<span class="stage-label">位客户</span>
</div>
<div class="stage-percentage">{{ getPercentage(stage.count) }}%</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
// 定义props
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
selectedStage: {
type: String,
default: 'all'
}
});
// 定义emits
const emit = defineEmits(['stage-select']);
// 计算总客户数
const totalCustomers = computed(() => {
const baseStages = [
props.data.newData || 120,
props.data.addedWechat || 85,
props.data.filledForm || 65,
props.data.phoneCall || 45,
props.data.lessons || 32,
props.data.deposit || 25,
props.data.followUp || 18,
props.data.closed || 12
];
return Math.max(...baseStages);
});
// 销售阶段数据
const stages = computed(() => [
{ id: 0, name: 'all', displayName: '全部', count: totalCustomers.value, color: '#f3f4f6' },
{ id: 1, name: '新数据', displayName: '新数据', count: props.data.newData || 120, color: '#e3f2fd' },
{ id: 2, name: '已加微', displayName: '已加微', count: props.data.addedWechat || 85, color: '#bbdefb' },
{ id: 3, name: '已填表单', displayName: '已填表单', count: props.data.filledForm || 65, color: '#90caf9' },
{ id: 4, name: '20分钟通话', displayName: '20分钟通话', count: props.data.phoneCall || 45, color: '#64b5f6' },
{ id: 5, name: '课1-4', displayName: '课1-4', count: props.data.lessons || 32, color: '#42a5f5' },
{ id: 6, name: '付定金', displayName: '付定金', count: props.data.deposit || 25, color: '#2196f3' },
{ id: 7, name: '催单', displayName: '催单', count: props.data.followUp || 18, color: '#1e88e5' },
{ id: 8, name: '成交', displayName: '成交', count: props.data.closed || 12, color: '#1976d2' }
]);
// 计算百分比
const getPercentage = (count) => {
if (totalCustomers.value === 0) return 0;
return Math.round((count / totalCustomers.value) * 100);
};
// 选择阶段
const selectStage = (stageName) => {
emit('stage-select', stageName);
};
</script>
<style lang="scss" scoped>
// Color Palette
$primary: #3b82f6;
$success: #22c55e;
$warning: #f59e0b;
$danger: #ef4444;
$slate-100: #f1f5f9;
$slate-200: #e2e8f0;
$slate-300: #cbd5e1;
$slate-400: #94a3b8;
$slate-500: #64748b;
$slate-600: #475569;
$slate-700: #334155;
$slate-800: #1e293b;
$white: #ffffff;
.sales-timeline {
padding: 1.5rem;
background: $white;
border-radius: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
@media (max-width: 768px) {
padding: 1rem;
}
}
.timeline-container {
position: relative;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
overflow-x: auto;
padding: 2rem 1rem 1rem 1rem;
@media (max-width: 768px) {
padding: 1.5rem 0.5rem 0.5rem 0.5rem;
-webkit-overflow-scrolling: touch; /* 改善iOS滚动体验 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
&::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
}
@media (max-width: 480px) {
padding: 1rem 0.25rem 0.25rem 0.25rem;
}
}
.timeline-line {
position: absolute;
top: 4rem;
left: 2rem;
right: 2rem;
height: 3px;
background: linear-gradient(to right, $primary, $success);
border-radius: 2px;
// z-index: 1;
@media (max-width: 768px) {
top: 3rem;
left: 1.5rem;
right: 1.5rem;
}
}
.timeline-stage {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
flex: 1;
min-width: 0;
cursor: pointer;
transition: all 0.3s ease;
border-radius: 8px;
padding: 0.5rem 8px;
@media (max-width: 768px) {
gap: 0.75rem;
min-width: 120px; /* 确保每个阶段有最小宽度 */
}
@media (max-width: 480px) {
gap: 0.5rem;
min-width: 100px;
padding: 0.25rem 4px;
}
&:hover {
background-color: rgba(59, 130, 246, 0.05);
transform: translateY(-2px);
.marker-circle {
border-color: $primary;
transform: scale(1.1);
}
}
&.selected {
background-color: rgba(59, 130, 246, 0.1);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
.marker-circle {
border-color: $primary;
background: $primary;
transform: scale(1.2);
.stage-number {
color: $white;
}
}
}
&.active {
.marker-circle {
background: linear-gradient(135deg, $primary, $success);
color: $white;
transform: scale(1.1);
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3);
}
.stage-content {
.stage-title {
color: $slate-800;
font-weight: 600;
}
.stage-count {
color: $primary;
font-weight: 700;
}
}
}
}
.stage-marker {
position: relative;
z-index: 3;
.marker-circle {
width: 4rem;
height: 4rem;
border-radius: 50%;
background: $slate-200;
border: 3px solid $white;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
z-index: 3;
@media (max-width: 768px) {
width: 3rem;
height: 3rem;
}
.stage-number {
font-size: 1.125rem;
font-weight: 700;
color: $slate-600;
@media (max-width: 768px) {
font-size: 1rem;
}
}
}
}
.stage-content {
text-align: center;
width: 100%;
.stage-title {
font-size: 1.25rem;
font-weight: 500;
color: $slate-700;
margin: 0 0 0.75rem 0;
@media (max-width: 768px) {
font-size: 1.125rem;
}
}
.stage-stats {
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
.stage-count {
font-size: 2rem;
font-weight: 700;
color: $slate-600;
line-height: 1;
@media (max-width: 768px) {
font-size: 1.75rem;
}
}
.stage-label {
font-size: 0.875rem;
color: $slate-500;
font-weight: 500;
}
}
.stage-percentage {
font-size: 0.875rem;
color: $slate-400;
font-weight: 500;
background: $slate-100;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
display: inline-block;
}
}
</style>

View File

@@ -0,0 +1,723 @@
<template>
<div class="sales-timeline-with-task-list">
<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>
<div class="task-body">
<div class="actionable-list">
<div class="items-grid">
<div
v-for="item in contacts"
:key="item.id"
:id="`contact-item-${item.id}`"
class="action-item"
:class="[getHealthIndicator(item.health).class, { 'active': item.id === selectedContactId }]"
@click="selectContact(item.id)">
<div class="item-header">
<div class="item-name-group">
<span class="item-name">{{ item.name }}</span>
</div>
<span class="item-time">{{ item.time }}</span>
</div>
<div class="item-footer">
<div class="profession-education">
<span class="profession">{{ item.profession || '公务员' }}</span>
<span class="education">{{ item.education || '高中' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
// 定义props
const props = defineProps({
// SalesTimeline props
data: {
type: Object,
default: () => ({})
},
selectedStage: {
type: String,
default: 'all'
},
// TaskList props
contacts: {
type: Array,
required: true
},
selectedContactId: {
type: Number,
default: null
}
});
// 定义emits
const emit = defineEmits(['stage-select', 'select-contact']);
// SalesTimeline methods
// 计算总客户数
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: '全部', 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.addedWechat || 85, color: '#bbdefb' },
{ id: 4, name: '已填表单', displayName: '已填表单', count: props.data.filledForm || 65, color: '#90caf9' },
{ id: 5, name: '已20分钟通话', displayName: '已20分钟通话', count: props.data.phoneCall || 45, color: '#64b5f6' },
{ id: 6, name: '课1-4', displayName: '课1-4', count: props.data.lessons || 32, color: '#42a5f5' },
{ id: 7, name: '点击未支付', displayName: '点击未支付', count: props.data.clickedNotPaid || 25, color: '#2196f3' },
{ id: 8, name: '付定金', displayName: '付定金', count: props.data.deposit || 18, color: '#1e88e5' },
{ id: 9, name: '定金转化', displayName: '定金转化', count: props.data.depositConverted || 18, color: '#1e88e5' },
{ id: 10, 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);
};
// TaskList methods
const selectContact = (id) => {
emit('select-contact', id);
};
const getHealthIndicator = (score) => {
if (score > 80) return { class: 'health-good', text: '健康', textColor: 'text-green' };
if (score > 50) return { class: 'health-ok', text: '一般', textColor: 'text-amber' };
return { class: 'health-risk', text: '高风险', textColor: 'text-red' };
};
</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;
$primary: #3b82f6;
$success: #22c55e;
$warning: #f59e0b;
$danger: #ef4444;
.sales-timeline-with-task-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
// Sales Timeline Styles
.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;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
@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;
@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;
}
}
// Task List Styles
.task-body {
padding: 1rem;
min-height: 15vh;
max-height: 50vh;
background: $white;
border-radius: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
@media (max-width: 768px) {
padding: 0 1rem;
}
@media (max-width: 480px) {
padding: 0 0.75rem;
}
}
.actionable-list {
display: flex;
flex-direction: column;
}
.items-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 0.45rem;
@media (max-width: 768px) {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
@media (max-width: 480px) {
grid-template-columns: repeat(2, 1fr);
gap: 0.375rem;
}
}
.action-item {
background-color: $white;
padding: 0.25rem;
border-radius: 0.5rem;
border: 1px solid $slate-200;
border-left-width: 4px;
cursor: pointer;
transition: all 0.2s ease-in-out;
min-height: 50px;
display: flex;
flex-direction: column;
justify-content: space-between;
@media (max-width: 768px) {
padding: 0.75rem;
min-height: 90px;
}
@media (max-width: 480px) {
padding: 0.625rem;
min-height: 50px;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(0,0,0,0.05);
@media (max-width: 768px) {
transform: none;
}
}
@media (max-width: 768px) {
&:active {
transform: scale(0.98);
background-color: $slate-50;
}
}
&.active {
background-color: #eef2ff;
border-color: $indigo;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
@media (max-width: 768px) {
margin-bottom: 0.5rem;
}
@media (max-width: 480px) {
margin-bottom: 0.375rem;
}
}
.item-footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
@media (max-width: 768px) {
gap: 0.375rem;
}
@media (max-width: 480px) {
gap: 0.25rem;
}
}
.item-name-group {
display: flex;
align-items: center;
.icon {
margin-right: 0.5rem;
flex-shrink: 0;
}
.item-name {
font-weight: 600;
font-size: 0.9rem;
line-height: 1.3;
color: $slate-800;
@media (max-width: 768px) {
font-size: 0.875rem;
}
@media (max-width: 480px) {
font-size: 0.8125rem;
}
}
}
.item-time {
font-size: 0.75rem;
color: $slate-500;
flex-shrink: 0;
white-space: nowrap;
@media (max-width: 768px) {
font-size: 0.6875rem;
}
@media (max-width: 480px) {
font-size: 0.625rem;
}
}
.health-status {
font-size: 0.75rem;
font-weight: bold;
@media (max-width: 768px) {
font-size: 0.6875rem;
}
@media (max-width: 480px) {
font-size: 0.625rem;
}
}
}
// Profession and Education
.profession-education {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
@media (max-width: 480px) {
gap: 0.5rem;
}
.profession, .education {
font-size: 0.75rem;
border-radius: 0.375rem;
font-weight: 500;
@media (max-width: 768px) {
font-size: 0.6875rem;
padding: 0.1875rem 0.375rem;
}
@media (max-width: 480px) {
font-size: 0.625rem;
padding: 0.125rem 0.25rem;
}
}
.profession {
background-color: #e0f2fe;
color: #0277bd;
}
.education {
background-color: #f3e5f5;
color: #7b1fa2;
}
}
// 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;
&.tag-high-intent { background-color: #fee2e2; color: #991b1b; }
&.tag-super-hot { background-color: #fef3c7; color: #92400e; }
&.tag-no-follow-up { background-color: #fef9c3; color: #92400e; }
&.tag-sleeping { background-color: #dbeafe; color: #1e40af; }
&.concern-tag { background-color: $slate-200; color: $slate-700; }
}
// Health Status Colors
.health-good { border-color: $green; }
.health-ok { border-color: $amber; }
.health-risk { border-color: $red; }
.text-green { color: $green; }
.text-amber { color: $amber; }
.text-red { color: $red; }
// Icon SVG helper
:deep(.icon-svg) {
width: 1.25rem;
height: 1.25rem;
}
:deep(.icon) {
display: flex;
align-items: center;
}
:deep(.icon-svg) {
&.high { color: $red; }
&.recommended { color: $blue; }
&.new { color: $green; }
}
/* 移动端优化 */
@media (max-width: 768px) {
.task-body {
padding: 0.75rem;
max-height: 400px;
}
.items-grid {
gap: 0.5rem;
}
.action-item {
padding: 0.75rem;
.item-header {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
.item-name {
font-size: 0.875rem;
}
.item-time {
font-size: 0.75rem;
align-self: flex-end;
}
}
.item-tags {
gap: 0.25rem;
flex-wrap: wrap;
.tag {
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
}
}
.item-health {
margin-top: 0.5rem;
.health-score {
font-size: 0.75rem;
}
}
}
}
/* 小屏幕优化 */
@media (max-width: 480px) {
.task-body {
padding: 0.5rem;
max-height: 300px;
}
.action-item {
padding: 0.5rem;
.item-header {
.item-name {
font-size: 0.75rem;
line-height: 1.2;
}
.item-time {
font-size: 0.625rem;
}
}
.item-tags {
margin-top: 0.25rem;
.tag {
padding: 0.0625rem 0.25rem;
font-size: 0.5rem;
}
}
.item-health {
margin-top: 0.25rem;
.health-score {
font-size: 0.625rem;
}
.health-bar {
height: 3px;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,104 @@
<template>
<div class="chart-container">
<div class="chart-header">
<h3>客户类型占比</h3>
<select v-model="customerTypeCategory" @change="updateChart" class="chart-select">
<option value="age">年龄</option>
<option value="profession">职业</option>
<option value="childGrade">孩子年级</option>
<option value="region">地域</option>
</select>
</div>
<div class="chart-content">
<div ref="customerTypeChartRef" class="customer-type-chart"></div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue';
import * as echarts from 'echarts';
const customerTypeChartRef = ref(null);
let customerTypeChart = null;
const customerTypeCategory = ref('age');
const customerTypeData = reactive({
age: [{ value: 120, name: '18-25岁' }, { value: 200, name: '26-35岁' }, { value: 150, name: '36-45岁' }, { value: 80, name: '46-55岁' }, { value: 50, name: '55岁以上' }],
profession: [{ value: 180, name: '企业管理者' }, { value: 120, name: '教师' }, { value: 100, name: '医生' }, { value: 90, name: '工程师' }, { value: 110, name: '其他' }],
childGrade: [{ value: 80, name: '幼儿园' }, { value: 150, name: '小学' }, { value: 180, name: '初中' }, { value: 120, name: '高中' }, { value: 70, name: '大学' }],
region: [{ value: 200, name: '北京' }, { value: 150, name: '上海' }, { value: 120, name: '广州' }, { value: 100, name: '深圳' }, { value: 130, name: '其他' }]
});
const updateChart = () => {
if (!customerTypeChart) return;
const currentData = customerTypeData[customerTypeCategory.value];
const option = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: currentData.map(item => item.name), axisTick: { alignWithLabel: true } },
yAxis: { type: 'value' },
series: [{
name: '客户数量', type: 'bar', barWidth: '60%',
data: currentData.map(item => item.value),
itemStyle: {
color: (params) => ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de'][params.dataIndex % 5]
}
}]
};
customerTypeChart.setOption(option, true);
};
const initChart = () => {
if (!customerTypeChartRef.value) return;
customerTypeChart = echarts.init(customerTypeChartRef.value);
updateChart();
};
const resizeChart = () => customerTypeChart?.resize();
onMounted(() => {
initChart();
window.addEventListener('resize', resizeChart);
});
onBeforeUnmount(() => {
customerTypeChart?.dispose();
window.removeEventListener('resize', resizeChart);
});
</script>
<style lang="scss" scoped>
.chart-container {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
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: #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,346 @@
<template>
<div class="group-comparison">
<!-- 综合排名 -->
<div class="ranking-section">
<h3>综合表现排名</h3>
<div class="ranking-grid compact">
<div
v-for="(group, index) in sortedGroups"
:key="group.id"
class="ranking-card"
:class="getRankingClass(index)"
@click="$emit('select-group', group)"
>
<div class="rank-badge">{{ index + 1 }}</div>
<div class="group-info">
<div class="group-name">{{ group.name }}</div>
<div class="group-leader">{{ group.leader }}</div>
</div>
<div class="performance-score">
<div class="score">{{ calculateScore(group) }}</div>
<div class="score-label">综合分</div>
</div>
<div class="key-metrics">
<div class="mini-metric">
<span class="mini-label">业绩</span>
<span class="mini-value">{{ formatCurrency(group.todayPerformance) }}</span>
</div>
<div class="mini-metric">
<span class="mini-label">转化</span>
<span class="mini-value">{{ group.conversionRate }}%</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
groups: {
type: Array,
required: true
}
})
const emit = defineEmits(['select-group'])
// 按综合表现排序的组别
const sortedGroups = computed(() => {
return [...props.groups].sort((a, b) => calculateScore(b) - calculateScore(a))
})
// 计算综合分数
const calculateScore = (group) => {
const performanceScore = (group.todayPerformance / 200000) * 30
const conversionScore = (group.conversionRate / 10) * 25
const clientScore = (group.newClients / 50) * 25
const dealScore = (group.deals / 20) * 20
return Math.round(performanceScore + conversionScore + clientScore + dealScore)
}
// 格式化指标值
const formatMetricValue = (value, unit) => {
switch (unit) {
case 'currency':
return formatCurrency(value)
case 'percent':
return value + '%'
case 'number':
return value.toString()
default:
return value
}
}
// 格式化货币
const formatCurrency = (value) => {
if (value >= 10000) {
return (value / 10000).toFixed(1) + '万'
}
return value.toLocaleString()
}
// 获取进度条宽度
const getProgressWidth = (value, metricKey) => {
const maxValues = {
todayPerformance: 200000,
conversionRate: 10,
newClients: 50,
deals: 20
}
return Math.min((value / maxValues[metricKey]) * 100, 100)
}
// 获取表现等级样式
const getPerformanceClass = (value, metricKey) => {
const thresholds = {
todayPerformance: { excellent: 120000, good: 80000 },
conversionRate: { excellent: 6, good: 4 },
newClients: { excellent: 25, good: 15 },
deals: { excellent: 8, good: 5 }
}
const threshold = thresholds[metricKey]
if (value >= threshold.excellent) return 'excellent'
if (value >= threshold.good) return 'good'
return 'poor'
}
// 获取排名样式
const getRankingClass = (index) => {
if (index === 0) return 'rank-1'
if (index === 1) return 'rank-2'
if (index === 2) return 'rank-3'
return 'rank-other'
}
// 获取趋势图标
const getTrendIcon = (trend) => {
const icons = {
up: '↗',
down: '↘',
stable: '→'
}
return icons[trend] || '→'
}
// 获取预警图标
const getAlertIcon = (level) => {
const icons = {
warning: '⚠️',
info: '',
urgent: '🚨'
}
return icons[level] || ''
}
</script>
<style lang="scss" scoped>
.group-comparison {
background: white;
border-radius: 12px;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
height: 24rem;
overflow: auto;
// 综合排名
.ranking-section {
// margin-bottom: 2rem;
h3 {
font-size: 1.1rem;
font-weight: 600;
color: #374151;
margin: 0 0 1rem 0;
}
.ranking-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
&.compact {
grid-template-columns: repeat(1, 1fr);
gap: 0.75rem;
.ranking-card {
padding: 0.75rem;
gap: 0.5rem;
.rank-badge {
width: 24px;
height: 24px;
font-size: 0.8rem;
}
.group-info {
.group-name {
font-size: 0.9rem;
}
.group-leader {
font-size: 0.75rem;
}
}
.performance-score {
.score {
font-size: 1.1rem;
}
.score-label {
font-size: 0.7rem;
}
}
.key-metrics {
.mini-metric {
.mini-label {
font-size: 0.7rem;
}
.mini-value {
font-size: 0.8rem;
}
}
}
}
}
}
.ranking-card {
display: flex;
align-items: center;
padding: 1rem;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.rank-1 {
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
border: 2px solid #f59e0b;
}
&.rank-2 {
background: linear-gradient(135deg, #c0c0c0 0%, #e5e7eb 100%);
border: 2px solid #9ca3af;
}
&.rank-3 {
background: linear-gradient(135deg, #cd7f32 0%, #d97706 100%);
border: 2px solid #b45309;
}
&.rank-other {
background: white;
border: 1px solid #e5e7eb;
}
.rank-badge {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.2rem;
margin-right: 1rem;
background: rgba(255, 255, 255, 0.9);
color: #1f2937;
}
.group-info {
flex: 1;
.group-name {
font-weight: 600;
color: #1f2937;
margin-bottom: 0.25rem;
}
.group-leader {
font-size: 0.85rem;
color: #6b7280;
}
}
.performance-score {
text-align: center;
margin: 0 1rem;
.score {
font-size: 1.5rem;
font-weight: bold;
color: #1f2937;
}
.score-label {
font-size: 0.75rem;
color: #6b7280;
}
}
.key-metrics {
display: flex;
flex-direction: column;
gap: 0.25rem;
.mini-metric {
display: flex;
justify-content: space-between;
gap: 0.5rem;
.mini-label {
font-size: 0.75rem;
color: #6b7280;
}
.mini-value {
font-size: 0.75rem;
font-weight: 600;
color: #1f2937;
}
}
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.group-comparison {
padding: 0.75rem;
.ranking-grid {
grid-template-columns: 1fr;
&.compact {
grid-template-columns: 1fr;
}
}
.ranking-card {
.key-metrics {
display: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,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);
min-height: 380px; /* 保持与其他卡片高度一致 */
display: flex;
flex-direction: column;
}
.chart-header {
padding: 20px 20px 16px;
border-bottom: 1px solid #ebeef5;
h3 {
margin: 0;
color: #303133;
font-size: 18px;
font-weight: 600;
}
}
.chart-content {
padding: 20px;
flex-grow: 1;
overflow: hidden; /* 防止内容溢出 */
}
/* 排行榜核心样式 */
.problem-ranking {
overflow-y: auto;
/* 自定义滚动条样式 (可选) */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
}
}
.ranking-item {
display: flex;
align-items: center;
padding: 12px 16px;
margin-bottom: 8px;
border-radius: 8px;
background: #f8f9fa;
border: 1px solid #e9ecef;
transition: all 0.3s ease;
&:last-child {
margin-bottom: 0;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
.rank-number {
margin-right: 16px;
}
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
font-weight: bold;
font-size: 14px;
color: white;
flex-shrink: 0;
&.badge-gold {
background: linear-gradient(135deg, #ffd700, #ffb300);
}
&.badge-silver {
background: linear-gradient(135deg, #c0c0c0, #a8a8a8);
}
&.badge-bronze {
background: linear-gradient(135deg, #cd7f32, #b8860b);
}
&.badge-default {
background: linear-gradient(135deg, #6c757d, #495057);
}
}
.problem-info {
flex: 1;
margin-right: 16px;
}
.problem-name {
font-size: 16px;
font-weight: 600;
color: #212529;
}
.problem-percentage {
display: flex;
flex-direction: column;
align-items: flex-end;
min-width: 80px;
}
.percentage {
font-size: 16px;
font-weight: bold;
color: #495057;
margin-bottom: 4px;
}
.progress-bar {
width: 60px;
height: 6px;
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
.rank-first & { background: linear-gradient(90deg, #ffd700, #ffb300); }
.rank-second & { background: linear-gradient(90deg, #c0c0c0, #a8a8a8); }
.rank-third & { background: linear-gradient(90deg, #cd7f32, #b8860b); }
.rank-other & { background: linear-gradient(90deg, #007bff, #0056b3); }
}
/* 空状态样式 */
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #909399;
font-size: 16px;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,80 @@
<template>
<div class="dashboard-card">
<div class="card-header">
<h3>转化漏斗</h3>
</div>
<div class="funnel-chart">
<div v-for="(stage, index) in funnelData" :key="stage.name" class="funnel-stage">
<div class="stage-bar" :style="{ width: stage.percentage + '%', backgroundColor: stage.color }">
<span class="stage-label">{{ stage.name }}</span>
<span class="stage-count">{{ stage.count }}</span>
</div>
<div class="stage-percentage">{{ stage.percentage }}%</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
defineProps({
funnelData: Array
});
</script>
<style scoped>
.dashboard-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 20px;
display: flex;
flex-direction: column;
flex: 1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-header h3 {
margin: 0;
font-size: 18px;
}
.funnel-chart {
display: flex;
flex-direction: column;
gap: 10px;
}
.funnel-stage {
display: flex;
align-items: center;
}
.stage-bar {
height: 30px;
line-height: 30px;
color: white;
padding: 0 10px;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
transition: width 0.5s ease-in-out;
}
.stage-label {
font-weight: bold;
}
.stage-percentage {
margin-left: 10px;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div class="dashboard-card">
<div class="card-header">
<h3>核心业绩指标</h3>
<span class="metric-period">本月</span>
</div>
<div class="kpi-metrics">
<div class="kpi-item">
<div class="kpi-label">销售额</div>
<div class="kpi-value">¥{{ formatNumber(kpiData.salesAmount) }}</div>
<div class="kpi-trend" :class="kpiData.salesTrend > 0 ? 'positive' : 'negative'">
{{ kpiData.salesTrend > 0 ? '+' : '' }}{{ kpiData.salesTrend }}%
</div>
</div>
<div class="kpi-item">
<div class="kpi-label">成交客户</div>
<div class="kpi-value">{{ kpiData.dealCustomers }}</div>
<div class="kpi-trend" :class="kpiData.customerTrend > 0 ? 'positive' : 'negative'">
{{ kpiData.customerTrend > 0 ? '+' : '' }}{{ kpiData.customerTrend }}%
</div>
</div>
<div class="kpi-item">
<div class="kpi-label">转化率</div>
<div class="kpi-value">{{ kpiData.conversionRate }}%</div>
<div class="kpi-trend" :class="kpiData.conversionTrend > 0 ? 'positive' : 'negative'">
{{ kpiData.conversionTrend > 0 ? '+' : '' }}{{ kpiData.conversionTrend }}%
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
defineProps({
kpiData: Object,
formatNumber: Function
});
function formatNumber(num) {
return num.toLocaleString();
}
</script>
<style scoped>
.dashboard-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 20px;
display: flex;
flex-direction: column;
flex: 1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-header h3 {
margin: 0;
font-size: 18px;
}
.metric-period {
font-size: 14px;
color: #666;
}
.kpi-metrics {
display: flex;
justify-content: space-around;
text-align: center;
}
.kpi-item {
flex: 1;
}
.kpi-label {
font-size: 14px;
color: #666;
margin-bottom: 5px;
}
.kpi-value {
font-size: 24px;
font-weight: bold;
margin-bottom: 5px;
}
.kpi-trend {
font-size: 14px;
}
.kpi-trend.positive {
color: #4CAF50;
}
.kpi-trend.negative {
color: #F44336;
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div class="dashboard-card">
<div class="card-header">
<h3>优质通话</h3>
<button class="view-all-btn">查看全部</button>
</div>
<div class="quality-calls">
<div v-for="call in qualityCalls.slice(0, 3)" :key="call.id" class="call-item">
<div class="call-info">
<div class="caller-name">{{ call.callerName }}</div>
<div class="call-details">
<span class="duration">{{ call.duration }}分钟</span>
<span class="score">评分: {{ call.score }}/10</span>
</div>
</div>
<div class="call-actions">
<button class="play-btn" @click="$emit('play-call', call.id)">
<i class="icon-play"></i>
</button>
<button class="download-btn" @click="$emit('download-call', call.id)">
<i class="icon-download"></i>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
defineProps({
qualityCalls: Array
});
defineEmits(['play-call', 'download-call']);
</script>
<style scoped>
.dashboard-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 20px;
display: flex;
flex-direction: column;
flex: 1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-header h3 {
margin: 0;
font-size: 18px;
}
.view-all-btn {
background: none;
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
}
.quality-calls .call-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
.quality-calls .call-item:last-child {
border-bottom: none;
}
.caller-name {
font-weight: bold;
}
.call-details {
font-size: 12px;
color: #666;
}
.call-actions button {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
margin-left: 10px;
}
.icon-play::before { content: '▶'; }
.icon-download::before { content: '⬇'; }
</style>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

16
my-vue-app/vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server :{
host : "0.0.0.0"
}
})