feat: 初始化Vue项目并添加核心功能模块
添加了Vue3项目基础结构,包括路由、状态管理和API配置 实现了销售管理系统的核心功能模块,包括业绩看板、团队管理和客户分析 集成了Element Plus UI组件库和ECharts数据可视化 添加了全局样式和响应式布局支持
This commit is contained in:
24
my-vue-app/.gitignore
vendored
Normal file
24
my-vue-app/.gitignore
vendored
Normal 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
3
my-vue-app/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
5
my-vue-app/README.md
Normal file
5
my-vue-app/README.md
Normal 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
13
my-vue-app/index.html
Normal 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
43
my-vue-app/package.json
Normal 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
3870
my-vue-app/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
my-vue-app/public/index.html
Normal file
18
my-vue-app/public/index.html
Normal 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>
|
||||||
BIN
my-vue-app/public/recordings/test_recording.m4a
Normal file
BIN
my-vue-app/public/recordings/test_recording.m4a
Normal file
Binary file not shown.
15
my-vue-app/src/App.vue
Normal file
15
my-vue-app/src/App.vue
Normal 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>
|
||||||
0
my-vue-app/src/api/api.js
Normal file
0
my-vue-app/src/api/api.js
Normal file
BIN
my-vue-app/src/assets/states/yclogo.png
Normal file
BIN
my-vue-app/src/assets/states/yclogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 189 KiB |
114
my-vue-app/src/assets/styles/main.scss
Normal file
114
my-vue-app/src/assets/styles/main.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
221
my-vue-app/src/components/header.vue
Normal file
221
my-vue-app/src/components/header.vue
Normal 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
14
my-vue-app/src/main.js
Normal 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')
|
||||||
52
my-vue-app/src/router/index.js
Normal file
52
my-vue-app/src/router/index.js
Normal 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
|
||||||
66
my-vue-app/src/stores/user.js
Normal file
66
my-vue-app/src/stores/user.js
Normal 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
79
my-vue-app/src/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
my-vue-app/src/topOne/DirectorDashboard.md
Normal file
108
my-vue-app/src/topOne/DirectorDashboard.md
Normal 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)”。
|
||||||
|
- 鼠标悬停在某个月的条形上时,提示框同时显示该月的“销售额”和“环比增长率”。
|
||||||
|
- **交互与下钻路径:**
|
||||||
|
- **点击某一个条形**,下钻到 **【第二层:销售业绩诊断看板】**,并将筛选条件自动设为被点击的那个月份/季度。
|
||||||
|
|
||||||
|
#### 组件7:CLV / CAC 比率
|
||||||
|
- **标题:** 客户终身价值 vs. 客户获取成本 (CLV/CAC)
|
||||||
|
- **可视化类型:** 巨大数字计分卡
|
||||||
|
- **核心指标:** `平均CLV / 平均CAC`
|
||||||
|
- **设计细节:**
|
||||||
|
- 以比率形式展示,例如“4.2 : 1”。
|
||||||
|
- 数字旁边可附带一个微型趋势图(Sparkline),展示该比率在过去12个月的变化趋势。
|
||||||
|
- 鼠标悬停时,提示框显示:“*CLV: ¥8,400, CAC: ¥2,000。该比率反映了客户价值与获客成本的关系,理想状态应大于3。*”
|
||||||
|
- **交互与下钻路径:**
|
||||||
|
- **点击该组件**,下钻到 **【第二层:客户价值与成本分析看板】**。该看板将详细拆解CAC的构成(如各渠道广告费、销售人力成本等)和CLV的计算模型,并允许按客户来源渠道、首次购买课程等维度进行细分对比分析。
|
||||||
168
my-vue-app/src/utils/ChatService.js
Normal file
168
my-vue-app/src/utils/ChatService.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
227
my-vue-app/src/utils/https.js
Normal file
227
my-vue-app/src/utils/https.js
Normal 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 }
|
||||||
256
my-vue-app/src/views/login/login.vue
Normal file
256
my-vue-app/src/views/login/login.vue
Normal 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>
|
||||||
902
my-vue-app/src/views/maneger/components/MemberDetails.vue
Normal file
902
my-vue-app/src/views/maneger/components/MemberDetails.vue
Normal 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>
|
||||||
225
my-vue-app/src/views/maneger/components/PerformanceRanking.vue
Normal file
225
my-vue-app/src/views/maneger/components/PerformanceRanking.vue
Normal 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>
|
||||||
165
my-vue-app/src/views/maneger/components/SalesFunnel.vue
Normal file
165
my-vue-app/src/views/maneger/components/SalesFunnel.vue
Normal 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>
|
||||||
115
my-vue-app/src/views/maneger/components/TeamAlerts.vue
Normal file
115
my-vue-app/src/views/maneger/components/TeamAlerts.vue
Normal 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>
|
||||||
159
my-vue-app/src/views/maneger/components/TeamReport.vue
Normal file
159
my-vue-app/src/views/maneger/components/TeamReport.vue
Normal 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>
|
||||||
1642
my-vue-app/src/views/maneger/manager.vue
Normal file
1642
my-vue-app/src/views/maneger/manager.vue
Normal file
File diff suppressed because it is too large
Load Diff
992
my-vue-app/src/views/person/components/CustomerDetail.vue
Normal file
992
my-vue-app/src/views/person/components/CustomerDetail.vue
Normal file
@@ -0,0 +1,992 @@
|
|||||||
|
<template>
|
||||||
|
<div class="customer-detail-container">
|
||||||
|
<div v-if="selectedContact" class="customer-detail-content">
|
||||||
|
<!-- 头部信息 -->
|
||||||
|
<div class="customer-header">
|
||||||
|
<h3>{{ selectedContact.name }}</h3>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button
|
||||||
|
@click="startBasicAnalysis"
|
||||||
|
class="analysis-button"
|
||||||
|
:disabled="isBasicAnalysisLoading"
|
||||||
|
>
|
||||||
|
{{ isBasicAnalysisLoading ? '基础分析中...' : '基础信息分析' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="startSopAnalysis"
|
||||||
|
class="analysis-button sop-button"
|
||||||
|
:disabled="isSopAnalysisLoading"
|
||||||
|
>
|
||||||
|
{{ isSopAnalysisLoading ? 'SOP分析中...' : 'SOP通话分析' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="startDemandAnalysis"
|
||||||
|
class="analysis-button demand-button"
|
||||||
|
:disabled="isDemandAnalysisLoading"
|
||||||
|
>
|
||||||
|
{{ isDemandAnalysisLoading ? '诉求分析中...' : '客户诉求分析' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分析区域 -->
|
||||||
|
<div class="analysis-areas">
|
||||||
|
<!-- 上方两个区域 -->
|
||||||
|
<div class="top-row">
|
||||||
|
<!-- 基础信息分析 -->
|
||||||
|
<div class="analysis-section basic-analysis">
|
||||||
|
<div class="section-header">
|
||||||
|
<h4>基础信息分析</h4>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="text-content" v-if="basicAnalysisResult">
|
||||||
|
<div class="analysis-text" v-html="formattedBasicAnalysis"></div>
|
||||||
|
</div>
|
||||||
|
<div class="placeholder-text" v-else>
|
||||||
|
<p>点击"基础信息分析"按钮开始分析客户基础信息</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SOP通话分析 -->
|
||||||
|
<div class="analysis-section sop-analysis">
|
||||||
|
<div class="section-header">
|
||||||
|
<h4>SOP通话分析</h4>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="text-content" v-if="sopAnalysisResult">
|
||||||
|
<div class="analysis-text" v-html="formattedSopAnalysis"></div>
|
||||||
|
</div>
|
||||||
|
<div class="placeholder-text" v-else>
|
||||||
|
<p>点击"SOP通话分析"按钮开始分析通话记录</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 下方整行区域 -->
|
||||||
|
<div class="bottom-row">
|
||||||
|
<!-- 客户诉求分析 -->
|
||||||
|
<div class="analysis-section demand-analysis">
|
||||||
|
<div class="section-header">
|
||||||
|
<h4>客户诉求分析</h4>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="text-content" v-if="demandAnalysisResult">
|
||||||
|
<div class="analysis-text" v-html="formattedDemandAnalysis"></div>
|
||||||
|
</div>
|
||||||
|
<div class="placeholder-text" v-else>
|
||||||
|
<p>点击"客户诉求分析"按钮开始深度分析客户需求和诉求</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 未选择客户时的提示 -->
|
||||||
|
<div v-else class="no-selection">
|
||||||
|
<p>请选择一个客户查看详情</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue';
|
||||||
|
import { SimpleChatService } from '@/utils/ChatService.js';
|
||||||
|
import MarkdownIt from 'markdown-it';
|
||||||
|
|
||||||
|
// 定义props
|
||||||
|
const props = defineProps({
|
||||||
|
selectedContact: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分析结果状态
|
||||||
|
const basicAnalysisResult = ref(''); // 基础信息分析结果
|
||||||
|
const sopAnalysisResult = ref(''); // SOP通话分析结果
|
||||||
|
const demandAnalysisResult = ref(''); // 客户诉求分析结果
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
const isBasicAnalysisLoading = ref(false); // 基础分析加载状态
|
||||||
|
const isSopAnalysisLoading = ref(false); // SOP分析加载状态
|
||||||
|
const isDemandAnalysisLoading = ref(false); // 诉求分析加载状态
|
||||||
|
|
||||||
|
// Dify API配置
|
||||||
|
const DIFY_API_KEY_01 = 'app-wbR1P1j6kvdBK8Q1qXzdswzP';
|
||||||
|
const DIFY_API_KEY = 'app-37VXHRieOnq17BSury9ONavG';
|
||||||
|
// 初始化ChatService
|
||||||
|
const chatService_01 = new SimpleChatService(DIFY_API_KEY_01);
|
||||||
|
const chatService = new SimpleChatService(DIFY_API_KEY);
|
||||||
|
|
||||||
|
// 初始化markdown-it
|
||||||
|
const md = new MarkdownIt({
|
||||||
|
html: true,
|
||||||
|
linkify: true,
|
||||||
|
typographer: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算属性:格式化基础分析结果
|
||||||
|
const formattedBasicAnalysis = computed(() => {
|
||||||
|
if (!basicAnalysisResult.value) return '';
|
||||||
|
return md.render(basicAnalysisResult.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算属性:格式化SOP分析结果
|
||||||
|
const formattedSopAnalysis = computed(() => {
|
||||||
|
if (!sopAnalysisResult.value) return '';
|
||||||
|
return md.render(sopAnalysisResult.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算属性:格式化诉求分析结果
|
||||||
|
const formattedDemandAnalysis = computed(() => {
|
||||||
|
if (!demandAnalysisResult.value) return '';
|
||||||
|
return md.render(demandAnalysisResult.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听selectedContact变化,重置所有分析结果
|
||||||
|
watch(() => props.selectedContact, (newContact) => {
|
||||||
|
if (newContact) {
|
||||||
|
// 重置所有分析状态
|
||||||
|
basicAnalysisResult.value = '';
|
||||||
|
sopAnalysisResult.value = '';
|
||||||
|
demandAnalysisResult.value = '';
|
||||||
|
isBasicAnalysisLoading.value = false;
|
||||||
|
isSopAnalysisLoading.value = false;
|
||||||
|
isDemandAnalysisLoading.value = false;
|
||||||
|
} else {
|
||||||
|
// 清空所有结果
|
||||||
|
basicAnalysisResult.value = '';
|
||||||
|
sopAnalysisResult.value = '';
|
||||||
|
demandAnalysisResult.value = '';
|
||||||
|
isBasicAnalysisLoading.value = false;
|
||||||
|
isSopAnalysisLoading.value = false;
|
||||||
|
isDemandAnalysisLoading.value = false;
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// 基础信息分析
|
||||||
|
const startBasicAnalysis = async () => {
|
||||||
|
if (!props.selectedContact) return;
|
||||||
|
|
||||||
|
isBasicAnalysisLoading.value = true;
|
||||||
|
basicAnalysisResult.value = '';
|
||||||
|
|
||||||
|
const query = `请对客户进行基础信息分析:
|
||||||
|
客户姓名:${props.selectedContact.name}
|
||||||
|
联系电话:${props.selectedContact.phone || '未提供'}
|
||||||
|
邮箱:${props.selectedContact.email || '未提供'}
|
||||||
|
公司:${props.selectedContact.company || '未提供'}
|
||||||
|
职位:${props.selectedContact.position || '未提供'}
|
||||||
|
销售阶段:${props.selectedContact.salesStage || '未知'}
|
||||||
|
健康度:${props.selectedContact.health || '未知'}%
|
||||||
|
|
||||||
|
请分析客户的基本情况、背景信息和初步画像。`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await chatService_01.sendMessage(
|
||||||
|
query,
|
||||||
|
(update) => {
|
||||||
|
basicAnalysisResult.value = update.content;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
isBasicAnalysisLoading.value = false;
|
||||||
|
console.log('基础信息分析完成');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('基础信息分析失败:', error);
|
||||||
|
basicAnalysisResult.value = `分析失败: ${error.message}`;
|
||||||
|
isBasicAnalysisLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// SOP通话分析
|
||||||
|
const startSopAnalysis = async () => {
|
||||||
|
if (!props.selectedContact) return;
|
||||||
|
|
||||||
|
isSopAnalysisLoading.value = true;
|
||||||
|
sopAnalysisResult.value = '';
|
||||||
|
|
||||||
|
const query = `请对客户 ${props.selectedContact.name} 进行SOP通话分析:
|
||||||
|
|
||||||
|
基于标准销售流程(SOP),分析以下方面:
|
||||||
|
1. 通话质量评估
|
||||||
|
2. 销售流程执行情况
|
||||||
|
3. 客户响应度分析
|
||||||
|
4. 沟通效果评价
|
||||||
|
5. 改进建议
|
||||||
|
|
||||||
|
客户当前状态:${props.selectedContact.salesStage || '未知'}
|
||||||
|
健康度:${props.selectedContact.health || '未知'}%`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await chatService.sendMessage(
|
||||||
|
query,
|
||||||
|
(update) => {
|
||||||
|
sopAnalysisResult.value = update.content;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
isSopAnalysisLoading.value = false;
|
||||||
|
console.log('SOP通话分析完成');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SOP通话分析失败:', error);
|
||||||
|
sopAnalysisResult.value = `分析失败: ${error.message}`;
|
||||||
|
isSopAnalysisLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 客户诉求分析
|
||||||
|
const startDemandAnalysis = async () => {
|
||||||
|
if (!props.selectedContact) return;
|
||||||
|
|
||||||
|
isDemandAnalysisLoading.value = true;
|
||||||
|
demandAnalysisResult.value = '';
|
||||||
|
|
||||||
|
const query = `请对客户 ${props.selectedContact.name} 进行深度诉求分析:
|
||||||
|
|
||||||
|
请从以下维度分析客户的真实需求和诉求:
|
||||||
|
1. 显性需求分析(客户明确表达的需求)
|
||||||
|
2. 隐性需求挖掘(潜在的、未明确表达的需求)
|
||||||
|
3. 痛点识别(客户面临的主要问题和挑战)
|
||||||
|
4. 决策因素分析(影响客户决策的关键因素)
|
||||||
|
5. 价值期望(客户期望获得的价值和收益)
|
||||||
|
6. 风险顾虑(客户可能的担忧和顾虑)
|
||||||
|
7. 个性化建议(针对性的解决方案建议)
|
||||||
|
|
||||||
|
客户信息:
|
||||||
|
姓名:${props.selectedContact.name}
|
||||||
|
公司:${props.selectedContact.company || '未提供'}
|
||||||
|
职位:${props.selectedContact.position || '未提供'}
|
||||||
|
销售阶段:${props.selectedContact.salesStage || '未知'}
|
||||||
|
健康度:${props.selectedContact.health || '未知'}%`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await chatService.sendMessage(
|
||||||
|
query,
|
||||||
|
(update) => {
|
||||||
|
demandAnalysisResult.value = update.content;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
isDemandAnalysisLoading.value = false;
|
||||||
|
console.log('客户诉求分析完成');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('客户诉求分析失败:', error);
|
||||||
|
demandAnalysisResult.value = `分析失败: ${error.message}`;
|
||||||
|
isDemandAnalysisLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
// Color Palette
|
||||||
|
$slate-50: #f8fafc;
|
||||||
|
$slate-100: #f1f5f9;
|
||||||
|
$slate-200: #e2e8f0;
|
||||||
|
$slate-300: #cbd5e1;
|
||||||
|
$slate-400: #94a3b8;
|
||||||
|
$slate-500: #64748b;
|
||||||
|
$slate-600: #475569;
|
||||||
|
$slate-700: #334155;
|
||||||
|
$slate-800: #1e293b;
|
||||||
|
$white: #ffffff;
|
||||||
|
|
||||||
|
$blue: #3b82f6;
|
||||||
|
$green: #22c55e;
|
||||||
|
$amber: #f59e0b;
|
||||||
|
$red: #ef4444;
|
||||||
|
$indigo: #4f46e5;
|
||||||
|
$purple: #a855f7;
|
||||||
|
|
||||||
|
// 容器样式
|
||||||
|
.customer-detail-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
// PC端保持一致布局
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
// padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板端适配
|
||||||
|
@media (max-width: 1023px) and (min-width: 769px) {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户详情内容
|
||||||
|
.customer-detail-content {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户头部信息
|
||||||
|
.customer-header {
|
||||||
|
background: $white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid $slate-200;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
// PC端保持一致布局
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板端适配
|
||||||
|
@media (max-width: 1023px) and (min-width: 769px) {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
padding: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: $slate-800;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
// PC端保持一致布局
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板端适配
|
||||||
|
@media (max-width: 1023px) and (min-width: 769px) {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-button {
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: $blue;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
// PC端保持一致布局
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板端适配
|
||||||
|
@media (max-width: 1023px) and (min-width: 769px) {
|
||||||
|
padding: 11px 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: $slate-400;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sop-button {
|
||||||
|
background: $green;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.demand-button {
|
||||||
|
background: $purple;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #9333ea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分析区域
|
||||||
|
.analysis-areas {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上方行
|
||||||
|
.top-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
height: 45%;
|
||||||
|
|
||||||
|
// PC端保持一致布局
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板端适配
|
||||||
|
@media (max-width: 1023px) and (min-width: 769px) {
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下方行
|
||||||
|
.bottom-row {
|
||||||
|
height: 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分析区域样式
|
||||||
|
.analysis-section {
|
||||||
|
background: $white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid $slate-200;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// PC端保持一致布局
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板端适配
|
||||||
|
@media (max-width: 1023px) and (min-width: 769px) {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: $slate-50;
|
||||||
|
border-bottom: 1px solid $slate-200;
|
||||||
|
|
||||||
|
// PC端保持一致布局
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板端适配
|
||||||
|
@media (max-width: 1023px) and (min-width: 769px) {
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $slate-700;
|
||||||
|
|
||||||
|
// PC端保持一致布局
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板端适配
|
||||||
|
@media (max-width: 1023px) and (min-width: 769px) {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
// PC端保持一致布局
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板端适配
|
||||||
|
@media (max-width: 1023px) and (min-width: 769px) {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-content {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.analysis-text {
|
||||||
|
color: $slate-700;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-wrap: break-word;
|
||||||
|
|
||||||
|
// PC端保持一致布局
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板端适配
|
||||||
|
@media (max-width: 1023px) and (min-width: 769px) {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
background: $slate-50;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px dashed $slate-200;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: $slate-500;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
// PC端保持一致布局
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板端适配
|
||||||
|
@media (max-width: 1023px) and (min-width: 769px) {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不同分析区域的主题色
|
||||||
|
&.basic-analysis {
|
||||||
|
.section-header {
|
||||||
|
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sop-analysis {
|
||||||
|
.section-header {
|
||||||
|
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
color: $green;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.demand-analysis {
|
||||||
|
.section-header {
|
||||||
|
background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
color: $purple;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown样式
|
||||||
|
.analysis-text {
|
||||||
|
// Markdown样式
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin: 1rem 0 0.5rem 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $slate-800;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 1.25rem; }
|
||||||
|
h2 { font-size: 1.125rem; }
|
||||||
|
h3 { font-size: 1rem; }
|
||||||
|
h4 { font-size: 0.875rem; }
|
||||||
|
h5 { font-size: 0.75rem; }
|
||||||
|
h6 { font-size: 0.75rem; }
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-left: 4px solid $blue;
|
||||||
|
background: rgba(59, 130, 246, 0.05);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: $slate-100;
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: $slate-100;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1rem 0;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $slate-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $blue;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid $slate-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1rem 0;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid $slate-200;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: $slate-50;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未选择状态
|
||||||
|
.no-selection {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
background: $slate-50;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px dashed $slate-200;
|
||||||
|
color: $slate-500;
|
||||||
|
|
||||||
|
// PC端保持一致布局
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
border-radius: 12px;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板端适配
|
||||||
|
@media (max-width: 1023px) and (min-width: 769px) {
|
||||||
|
border-radius: 10px;
|
||||||
|
min-height: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
height: 400px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
height: 300px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
// PC端保持一致布局
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板端适配
|
||||||
|
@media (max-width: 1023px) and (min-width: 769px) {
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2.section-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: $slate-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $slate-700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context Panel
|
||||||
|
.section-card {
|
||||||
|
background-color: $white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
// padding: 1rem;
|
||||||
|
// margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分析区域布局优化
|
||||||
|
.analysis-areas {
|
||||||
|
// PC端保持一致布局
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板端适配
|
||||||
|
@media (max-width: 1023px) and (min-width: 769px) {
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下方行适配
|
||||||
|
.bottom-row {
|
||||||
|
// 移动端适配
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
height: auto;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏移动端适配
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
577
my-vue-app/src/views/person/components/FloatingTodo.vue
Normal file
577
my-vue-app/src/views/person/components/FloatingTodo.vue
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
<template>
|
||||||
|
<div class="floating-todo" :class="{ 'expanded': isExpanded }">
|
||||||
|
<!-- 悬浮按钮 -->
|
||||||
|
<div class="floating-btn" @click="toggleExpanded">
|
||||||
|
<i class="icon-calendar">📅</i>
|
||||||
|
<span class="todo-count" v-if="!isExpanded">{{ todayTodos.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 展开的内容面板 -->
|
||||||
|
<div class="todo-panel" v-show="isExpanded">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>今日待办</h3>
|
||||||
|
<button class="close-btn" @click="toggleExpanded">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-content">
|
||||||
|
<!-- 今日待办列表 -->
|
||||||
|
<div class="todo-section">
|
||||||
|
<h4>待办事项 ({{ todayTodos.length }})</h4>
|
||||||
|
<div class="todo-list">
|
||||||
|
<div
|
||||||
|
v-for="todo in todayTodos"
|
||||||
|
:key="todo.id"
|
||||||
|
class="todo-item"
|
||||||
|
:class="{ 'completed': todo.completed }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="todo.completed"
|
||||||
|
@change="updateTodo(todo)"
|
||||||
|
>
|
||||||
|
<span class="todo-text">{{ todo.text }}</span>
|
||||||
|
<span class="todo-time">{{ todo.time }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加新待办 -->
|
||||||
|
<div class="add-todo-section">
|
||||||
|
<h4>添加待办</h4>
|
||||||
|
<div class="add-todo-form">
|
||||||
|
<input
|
||||||
|
v-model="newTodoText"
|
||||||
|
type="text"
|
||||||
|
placeholder="输入待办事项..."
|
||||||
|
@keyup.enter="addTodo"
|
||||||
|
class="todo-input"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="newTodoTime"
|
||||||
|
type="time"
|
||||||
|
class="time-input"
|
||||||
|
>
|
||||||
|
<button @click="addTodo" class="add-btn">添加</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快捷操作 -->
|
||||||
|
<div class="quick-actions">
|
||||||
|
<button @click="addQuickTodo('回访重点客户')" class="quick-btn">回访重点客户</button>
|
||||||
|
<button @click="addQuickTodo('整理客户资料')" class="quick-btn">整理客户资料</button>
|
||||||
|
<button @click="addQuickTodo('准备明日计划')" class="quick-btn">准备明日计划</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue';
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const isExpanded = ref(false);
|
||||||
|
const newTodoText = ref('');
|
||||||
|
const newTodoTime = ref('');
|
||||||
|
|
||||||
|
// 待办事项数据
|
||||||
|
const todos = reactive([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
text: '回访王女士 - 付定金阶段',
|
||||||
|
time: '10:00',
|
||||||
|
completed: false,
|
||||||
|
date: new Date().toDateString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
text: '联系李先生 - 课程咨询',
|
||||||
|
time: '14:00',
|
||||||
|
completed: false,
|
||||||
|
date: new Date().toDateString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
text: '准备张总的合同材料',
|
||||||
|
time: '16:00',
|
||||||
|
completed: true,
|
||||||
|
date: new Date().toDateString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
text: '整理本周客户跟进报告',
|
||||||
|
time: '18:00',
|
||||||
|
completed: false,
|
||||||
|
date: new Date().toDateString()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 计算今日待办
|
||||||
|
const todayTodos = computed(() => {
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
return todos.filter(todo => todo.date === today);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
isExpanded.value = !isExpanded.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTodo = (todo) => {
|
||||||
|
// 这里可以添加保存到本地存储或发送到服务器的逻辑
|
||||||
|
console.log('Todo updated:', todo);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTodo = () => {
|
||||||
|
if (newTodoText.value.trim()) {
|
||||||
|
const newTodo = {
|
||||||
|
id: Date.now(),
|
||||||
|
text: newTodoText.value,
|
||||||
|
time: newTodoTime.value || '09:00',
|
||||||
|
completed: false,
|
||||||
|
date: new Date().toDateString()
|
||||||
|
};
|
||||||
|
todos.push(newTodo);
|
||||||
|
newTodoText.value = '';
|
||||||
|
newTodoTime.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addQuickTodo = (text) => {
|
||||||
|
const newTodo = {
|
||||||
|
id: Date.now(),
|
||||||
|
text: text,
|
||||||
|
time: '09:00',
|
||||||
|
completed: false,
|
||||||
|
date: new Date().toDateString()
|
||||||
|
};
|
||||||
|
todos.push(newTodo);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
// 可以从本地存储加载数据
|
||||||
|
const savedTodos = localStorage.getItem('floating-todos');
|
||||||
|
if (savedTodos) {
|
||||||
|
const parsed = JSON.parse(savedTodos);
|
||||||
|
todos.splice(0, todos.length, ...parsed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听数据变化,保存到本地存储
|
||||||
|
const saveTodos = () => {
|
||||||
|
localStorage.setItem('floating-todos', JSON.stringify(todos));
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.floating-todo {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
.floating-btn {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 25px rgba(102, 126, 234, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-calendar {
|
||||||
|
font-size: 24px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-count {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
right: -5px;
|
||||||
|
background: #ff4757;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 70px;
|
||||||
|
left: 0;
|
||||||
|
width: 350px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slideDown 0.3s ease;
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 15px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
padding: 20px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.todo-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-list {
|
||||||
|
.todo-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.completed {
|
||||||
|
opacity: 0.6;
|
||||||
|
|
||||||
|
.todo-text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
margin-right: 10px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-todo-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-todo-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.todo-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #5a6fd8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
.quick-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #495057;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
border-color: #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.expanded {
|
||||||
|
.floating-btn {
|
||||||
|
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端优化 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.floating-todo {
|
||||||
|
top: 15px;
|
||||||
|
left: 15px;
|
||||||
|
|
||||||
|
.floating-btn {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
|
||||||
|
.icon-calendar {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-count {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
font-size: 11px;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-panel {
|
||||||
|
width: 300px;
|
||||||
|
top: 60px;
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 12px 15px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
padding: 15px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item {
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
|
.todo-text {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-time {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-input {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-input {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn, .quick-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 小屏幕优化 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.floating-todo {
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
|
||||||
|
.floating-btn {
|
||||||
|
width: 45px;
|
||||||
|
height: 45px;
|
||||||
|
|
||||||
|
.icon-calendar {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-count {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-panel {
|
||||||
|
width: 280px;
|
||||||
|
top: 55px;
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item {
|
||||||
|
padding: 6px 0;
|
||||||
|
|
||||||
|
.todo-text {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-time {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-todo-form {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.todo-input, .time-input {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.quick-btn {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
625
my-vue-app/src/views/person/components/PersonalDashboard.vue
Normal file
625
my-vue-app/src/views/person/components/PersonalDashboard.vue
Normal 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>
|
||||||
599
my-vue-app/src/views/person/components/RawDataCards.vue
Normal file
599
my-vue-app/src/views/person/components/RawDataCards.vue
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
<template>
|
||||||
|
<div class="raw-data-cards">
|
||||||
|
<div class="cards-container">
|
||||||
|
<!-- 表单信息卡片 -->
|
||||||
|
<div class="data-card form-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="10,9 9,9 8,9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="card-title">表单信息</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="form-data-list">
|
||||||
|
<div v-for="(field, index) in formFields" :key="index" class="form-field">
|
||||||
|
<span class="field-label">{{ field.label }}:</span>
|
||||||
|
<span class="field-value">{{ field.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 聊天记录和通话录音卡片 -->
|
||||||
|
<div class="data-card communication-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="card-title">沟通记录</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<!-- Tab 切换 -->
|
||||||
|
<div class="tab-container">
|
||||||
|
<div class="tab-buttons">
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ active: activeTab === 'chat' }"
|
||||||
|
@click="activeTab = 'chat'"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
聊天记录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ active: activeTab === 'call' }"
|
||||||
|
@click="activeTab = 'call'"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
通话录音
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 内容 -->
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- 聊天记录内容 -->
|
||||||
|
<div v-if="activeTab === 'chat'" class="chat-content">
|
||||||
|
<div class="content-header">
|
||||||
|
<span class="content-count">共 {{ chatData.count }} 条消息</span>
|
||||||
|
<span class="content-time">最新: {{ chatData.lastMessage }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="message-list">
|
||||||
|
<div v-for="(message, index) in chatMessages" :key="index" class="message-item">
|
||||||
|
<div class="message-header">
|
||||||
|
<span class="message-sender">{{ message.sender }}</span>
|
||||||
|
<span class="message-time">{{ message.time }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="message-text">{{ message.content }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 通话录音内容 -->
|
||||||
|
<div v-if="activeTab === 'call'" class="call-content">
|
||||||
|
<div class="content-header">
|
||||||
|
<span class="content-count">共 {{ callData.count }} 次通话</span>
|
||||||
|
<span class="content-time">总时长: {{ callData.totalDuration }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="call-list">
|
||||||
|
<div v-for="(call, index) in callRecords" :key="index" class="call-item">
|
||||||
|
<div class="call-header">
|
||||||
|
<span class="call-type">{{ call.type }}</span>
|
||||||
|
<span class="call-duration">{{ call.duration }}</span>
|
||||||
|
<span class="call-time">{{ call.time }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="call-summary">{{ call.summary }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
selectedContact: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前激活的tab
|
||||||
|
const activeTab = ref('chat')
|
||||||
|
|
||||||
|
// 表单字段数据
|
||||||
|
const formFields = computed(() => {
|
||||||
|
const contact = props.selectedContact
|
||||||
|
if (!contact || !contact.details) {
|
||||||
|
return [
|
||||||
|
{ label: '姓名', value: '暂无数据' },
|
||||||
|
{ label: '联系方式', value: '暂无数据' },
|
||||||
|
{ label: '意向课程', value: '暂无数据' },
|
||||||
|
{ label: '预算范围', value: '暂无数据' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: '客户姓名', value: contact.name || '暂无' },
|
||||||
|
{ label: '孩子姓名', value: contact.details.childName || '暂无' },
|
||||||
|
{ label: '孩子年龄', value: contact.details.childAge ? `${contact.details.childAge}岁` : '暂无' },
|
||||||
|
{ label: '关注问题', value: contact.details.concerns?.join('、') || '暂无' },
|
||||||
|
{ label: '预算范围', value: contact.details.budget || '暂无' },
|
||||||
|
{ label: '偏好时间', value: contact.details.preferredTime || '暂无' },
|
||||||
|
{ label: '销售阶段', value: contact.salesStage || '暂无' },
|
||||||
|
{ label: '健康度', value: contact.health ? `${contact.health}%` : '暂无' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 聊天数据
|
||||||
|
const chatData = computed(() => ({
|
||||||
|
count: props.selectedContact?.chatCount || 127,
|
||||||
|
lastMessage: props.selectedContact?.lastMessage || '1小时前'
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 通话数据
|
||||||
|
const callData = computed(() => ({
|
||||||
|
count: props.selectedContact?.callCount || 5,
|
||||||
|
totalDuration: props.selectedContact?.totalCallDuration || '45分钟'
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 聊天消息列表
|
||||||
|
const chatMessages = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sender: '客户',
|
||||||
|
time: '今天 14:30',
|
||||||
|
content: '你好,我想了解一下数学课程的具体安排和费用情况。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: '我',
|
||||||
|
time: '今天 14:32',
|
||||||
|
content: '您好!我们的数学课程分为基础班和提高班,根据孩子的年龄和基础来安排。费用方面,基础班是6000元/期,提高班是8000元/期。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: '客户',
|
||||||
|
time: '今天 14:35',
|
||||||
|
content: '孩子现在8岁,数学基础一般,应该选择哪个班级比较合适?'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: '我',
|
||||||
|
time: '今天 14:37',
|
||||||
|
content: '建议先从基础班开始,我们会有专业的测评来确定孩子的具体水平,然后制定个性化的学习方案。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: '客户',
|
||||||
|
time: '今天 15:20',
|
||||||
|
content: '好的,那什么时候可以安排试听课呢?'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 通话记录列表
|
||||||
|
const callRecords = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: '呼出',
|
||||||
|
duration: '12分钟',
|
||||||
|
time: '今天 10:30',
|
||||||
|
summary: '初次沟通,了解客户基本需求。客户对数学课程比较感兴趣,孩子8岁,希望提高数学成绩。约定发送详细资料。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '呼入',
|
||||||
|
duration: '8分钟',
|
||||||
|
time: '昨天 16:45',
|
||||||
|
summary: '客户主动来电咨询价格和上课时间。解答了关于师资力量和教学方法的问题。客户表示需要和家人商量。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '呼出',
|
||||||
|
duration: '15分钟',
|
||||||
|
time: '3天前 14:20',
|
||||||
|
summary: '跟进客户需求,详细介绍了课程体系和教学理念。客户对一对一辅导很感兴趣,但对价格有些犹豫。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '呼出',
|
||||||
|
duration: '6分钟',
|
||||||
|
time: '5天前 11:15',
|
||||||
|
summary: '首次电话联系,简单介绍了公司和课程概况。客户表示有兴趣,约定后续详细沟通。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.raw-data-cards {
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.form-card::before {
|
||||||
|
background: linear-gradient(90deg, #10b981, #059669);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.communication-card::before {
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #1d4ed8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
|
||||||
|
.form-card & {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.communication-card & {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单字段样式
|
||||||
|
.form-data-list {
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab 容器样式
|
||||||
|
.tab-container {
|
||||||
|
.tab-buttons {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #3b82f6;
|
||||||
|
border-bottom-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
min-height: 300px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.content-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
|
||||||
|
.content-count {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聊天消息样式
|
||||||
|
.message-list {
|
||||||
|
.message-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9fafb;
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.message-sender {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通话记录样式
|
||||||
|
.call-list {
|
||||||
|
.call-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-left: 4px solid #3b82f6;
|
||||||
|
|
||||||
|
.call-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.call-type {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-duration {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-summary {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-description {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action {
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
padding-top: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 0;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover svg {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.raw-data-cards {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.cards-container {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-description {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
318
my-vue-app/src/views/person/components/SalesTimeline.vue
Normal file
318
my-vue-app/src/views/person/components/SalesTimeline.vue
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
<template>
|
||||||
|
<div class="sales-timeline">
|
||||||
|
<div class="timeline-container">
|
||||||
|
<div class="timeline-line"></div>
|
||||||
|
<div
|
||||||
|
v-for="(stage, index) in stages"
|
||||||
|
:key="stage.id"
|
||||||
|
class="timeline-stage"
|
||||||
|
:class="{ 'active': stage.count > 0, 'selected': selectedStage === stage.name }"
|
||||||
|
@click="selectStage(stage.name)"
|
||||||
|
>
|
||||||
|
<div class="stage-marker">
|
||||||
|
<div class="marker-circle">
|
||||||
|
<span class="stage-number">{{ index + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="marker-line" v-if="index < stages.length - 1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stage-content">
|
||||||
|
<h3 class="stage-title">{{ stage.displayName || stage.name }}</h3>
|
||||||
|
<div class="stage-stats">
|
||||||
|
<span class="stage-count">{{ stage.count }}</span>
|
||||||
|
<span class="stage-label">位客户</span>
|
||||||
|
</div>
|
||||||
|
<div class="stage-percentage">{{ getPercentage(stage.count) }}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
// 定义props
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
selectedStage: {
|
||||||
|
type: String,
|
||||||
|
default: 'all'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 定义emits
|
||||||
|
const emit = defineEmits(['stage-select']);
|
||||||
|
|
||||||
|
// 计算总客户数
|
||||||
|
const totalCustomers = computed(() => {
|
||||||
|
const baseStages = [
|
||||||
|
props.data.newData || 120,
|
||||||
|
props.data.addedWechat || 85,
|
||||||
|
props.data.filledForm || 65,
|
||||||
|
props.data.phoneCall || 45,
|
||||||
|
props.data.lessons || 32,
|
||||||
|
props.data.deposit || 25,
|
||||||
|
props.data.followUp || 18,
|
||||||
|
props.data.closed || 12
|
||||||
|
];
|
||||||
|
return Math.max(...baseStages);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 销售阶段数据
|
||||||
|
const stages = computed(() => [
|
||||||
|
{ id: 0, name: 'all', displayName: '全部', count: totalCustomers.value, color: '#f3f4f6' },
|
||||||
|
{ id: 1, name: '新数据', displayName: '新数据', count: props.data.newData || 120, color: '#e3f2fd' },
|
||||||
|
{ id: 2, name: '已加微', displayName: '已加微', count: props.data.addedWechat || 85, color: '#bbdefb' },
|
||||||
|
{ id: 3, name: '已填表单', displayName: '已填表单', count: props.data.filledForm || 65, color: '#90caf9' },
|
||||||
|
{ id: 4, name: '20分钟通话', displayName: '20分钟通话', count: props.data.phoneCall || 45, color: '#64b5f6' },
|
||||||
|
{ id: 5, name: '课1-4', displayName: '课1-4', count: props.data.lessons || 32, color: '#42a5f5' },
|
||||||
|
{ id: 6, name: '付定金', displayName: '付定金', count: props.data.deposit || 25, color: '#2196f3' },
|
||||||
|
{ id: 7, name: '催单', displayName: '催单', count: props.data.followUp || 18, color: '#1e88e5' },
|
||||||
|
{ id: 8, name: '成交', displayName: '成交', count: props.data.closed || 12, color: '#1976d2' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 计算百分比
|
||||||
|
const getPercentage = (count) => {
|
||||||
|
if (totalCustomers.value === 0) return 0;
|
||||||
|
return Math.round((count / totalCustomers.value) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选择阶段
|
||||||
|
const selectStage = (stageName) => {
|
||||||
|
emit('stage-select', stageName);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
// Color Palette
|
||||||
|
$primary: #3b82f6;
|
||||||
|
$success: #22c55e;
|
||||||
|
$warning: #f59e0b;
|
||||||
|
$danger: #ef4444;
|
||||||
|
$slate-100: #f1f5f9;
|
||||||
|
$slate-200: #e2e8f0;
|
||||||
|
$slate-300: #cbd5e1;
|
||||||
|
$slate-400: #94a3b8;
|
||||||
|
$slate-500: #64748b;
|
||||||
|
$slate-600: #475569;
|
||||||
|
$slate-700: #334155;
|
||||||
|
$slate-800: #1e293b;
|
||||||
|
$white: #ffffff;
|
||||||
|
|
||||||
|
.sales-timeline {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: $white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 2rem 1rem 1rem 1rem;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 1.5rem 0.5rem 0.5rem 0.5rem;
|
||||||
|
-webkit-overflow-scrolling: touch; /* 改善iOS滚动体验 */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE/Edge */
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome/Safari */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
padding: 1rem 0.25rem 0.25rem 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 4rem;
|
||||||
|
left: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(to right, $primary, $success);
|
||||||
|
border-radius: 2px;
|
||||||
|
// z-index: 1;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
top: 3rem;
|
||||||
|
left: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-stage {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 8px;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 120px; /* 确保每个阶段有最小宽度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 100px;
|
||||||
|
padding: 0.25rem 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(59, 130, 246, 0.05);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
|
||||||
|
.marker-circle {
|
||||||
|
border-color: $primary;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
|
||||||
|
|
||||||
|
.marker-circle {
|
||||||
|
border-color: $primary;
|
||||||
|
background: $primary;
|
||||||
|
transform: scale(1.2);
|
||||||
|
|
||||||
|
.stage-number {
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
.marker-circle {
|
||||||
|
background: linear-gradient(135deg, $primary, $success);
|
||||||
|
color: $white;
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-content {
|
||||||
|
.stage-title {
|
||||||
|
color: $slate-800;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-count {
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-marker {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
|
||||||
|
.marker-circle {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $slate-200;
|
||||||
|
border: 3px solid $white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-number {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $slate-600;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-content {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.stage-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $slate-700;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
.stage-count {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $slate-600;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $slate-500;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-percentage {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $slate-400;
|
||||||
|
font-weight: 500;
|
||||||
|
background: $slate-100;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
2277
my-vue-app/src/views/person/sale.vue
Normal file
2277
my-vue-app/src/views/person/sale.vue
Normal file
File diff suppressed because it is too large
Load Diff
912
my-vue-app/src/views/secondTop/components/ActionItems.vue
Normal file
912
my-vue-app/src/views/secondTop/components/ActionItems.vue
Normal 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>
|
||||||
246
my-vue-app/src/views/secondTop/components/CenterOverview.vue
Normal file
246
my-vue-app/src/views/secondTop/components/CenterOverview.vue
Normal 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>
|
||||||
407
my-vue-app/src/views/secondTop/components/CustomerDetail.vue
Normal file
407
my-vue-app/src/views/secondTop/components/CustomerDetail.vue
Normal 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>
|
||||||
104
my-vue-app/src/views/secondTop/components/CustomerType.vue
Normal file
104
my-vue-app/src/views/secondTop/components/CustomerType.vue
Normal 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>
|
||||||
1178
my-vue-app/src/views/secondTop/components/GoodMusic.vue
Normal file
1178
my-vue-app/src/views/secondTop/components/GoodMusic.vue
Normal file
File diff suppressed because it is too large
Load Diff
346
my-vue-app/src/views/secondTop/components/GroupComparison.vue
Normal file
346
my-vue-app/src/views/secondTop/components/GroupComparison.vue
Normal 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>
|
||||||
331
my-vue-app/src/views/secondTop/components/GroupRanking.vue
Normal file
331
my-vue-app/src/views/secondTop/components/GroupRanking.vue
Normal 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>
|
||||||
222
my-vue-app/src/views/secondTop/components/ProblemRanking.vue
Normal file
222
my-vue-app/src/views/secondTop/components/ProblemRanking.vue
Normal 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>
|
||||||
1117
my-vue-app/src/views/secondTop/components/seniorManager.vue
Normal file
1117
my-vue-app/src/views/secondTop/components/seniorManager.vue
Normal file
File diff suppressed because it is too large
Load Diff
1183
my-vue-app/src/views/secondTop/secondTop.vue
Normal file
1183
my-vue-app/src/views/secondTop/secondTop.vue
Normal file
File diff suppressed because it is too large
Load Diff
245
my-vue-app/src/views/senorManger/components/CenterOverview.vue
Normal file
245
my-vue-app/src/views/senorManger/components/CenterOverview.vue
Normal 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>
|
||||||
407
my-vue-app/src/views/senorManger/components/CustomerDetail.vue
Normal file
407
my-vue-app/src/views/senorManger/components/CustomerDetail.vue
Normal 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>
|
||||||
346
my-vue-app/src/views/senorManger/components/GroupComparison.vue
Normal file
346
my-vue-app/src/views/senorManger/components/GroupComparison.vue
Normal 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>
|
||||||
192
my-vue-app/src/views/senorManger/components/GroupRanking.vue
Normal file
192
my-vue-app/src/views/senorManger/components/GroupRanking.vue
Normal 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>
|
||||||
200
my-vue-app/src/views/senorManger/components/ProblemRanking.vue
Normal file
200
my-vue-app/src/views/senorManger/components/ProblemRanking.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
1641
my-vue-app/src/views/senorManger/components/manager.vue
Normal file
1641
my-vue-app/src/views/senorManger/components/manager.vue
Normal file
File diff suppressed because it is too large
Load Diff
1140
my-vue-app/src/views/senorManger/seniorManager.vue
Normal file
1140
my-vue-app/src/views/senorManger/seniorManager.vue
Normal file
File diff suppressed because it is too large
Load Diff
252
my-vue-app/src/views/topOne/components/CampManagement.vue
Normal file
252
my-vue-app/src/views/topOne/components/CampManagement.vue
Normal 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>
|
||||||
104
my-vue-app/src/views/topOne/components/CommunicationData.vue
Normal file
104
my-vue-app/src/views/topOne/components/CommunicationData.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>沟通总数据</h3>
|
||||||
|
<span class="metric-period">本周</span>
|
||||||
|
</div>
|
||||||
|
<div class="communication-cards">
|
||||||
|
<div class="comm-card">
|
||||||
|
<div class="card-icon">📞</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="card-label">总通话时长</div>
|
||||||
|
<div class="card-value">{{ communicationData.totalDuration }}小时</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comm-card">
|
||||||
|
<div class="card-icon">✅</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="card-label">有效沟通率</div>
|
||||||
|
<div class="card-value">{{ communicationData.effectiveRate }}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comm-card">
|
||||||
|
<div class="card-icon">⚡</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="card-label">首次响应时长</div>
|
||||||
|
<div class="card-value">{{ communicationData.firstResponseTime }}秒</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comm-card">
|
||||||
|
<div class="card-icon">📊</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="card-label">接通率</div>
|
||||||
|
<div class="card-value">{{ communicationData.connectionRate }}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps } from 'vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
communicationData: Object
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-card {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-period {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.communication-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
119
my-vue-app/src/views/topOne/components/CustomerProfile.vue
Normal file
119
my-vue-app/src/views/topOne/components/CustomerProfile.vue
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>客户画像</h3>
|
||||||
|
</div>
|
||||||
|
<div class="customer-profile">
|
||||||
|
<div class="profile-section">
|
||||||
|
<h4>家长类型分布</h4>
|
||||||
|
<div class="parent-types">
|
||||||
|
<div v-for="type in parentTypes" :key="type.name" class="parent-type-item">
|
||||||
|
<div class="type-info">
|
||||||
|
<span class="type-name">{{ type.name }}</span>
|
||||||
|
<span class="type-percentage">{{ type.percentage }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="type-bar">
|
||||||
|
<div class="type-fill" :style="{ width: type.percentage + '%', backgroundColor: type.color }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-section" style="margin-bottom: 25px;">
|
||||||
|
<h4>热门问题排行</h4>
|
||||||
|
<div class="hot-questions">
|
||||||
|
<div v-for="(question, index) in hotQuestions.slice(0, 3)" :key="question.id" class="question-item">
|
||||||
|
<div class="question-rank">{{ index + 1 }}</div>
|
||||||
|
<div class="question-content">
|
||||||
|
<div class="question-text">{{ question.text }}</div>
|
||||||
|
<div class="question-count">{{ question.count }}次咨询</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps } from 'vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
parentTypes: Array,
|
||||||
|
hotQuestions: Array
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-card {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
/* flex: 1; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-profile .profile-section h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-type-item {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-bar {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-questions .question-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-rank {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-text {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
93
my-vue-app/src/views/topOne/components/DataDetail.vue
Normal file
93
my-vue-app/src/views/topOne/components/DataDetail.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-card detail-section">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>数据详情</h3>
|
||||||
|
</div>
|
||||||
|
<div class="detail-content">
|
||||||
|
<div v-if="!selectedPerson" class="no-selection">
|
||||||
|
<div class="empty-icon">📊</div>
|
||||||
|
<p>请点击左侧表格中的人员查看详细数据</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="person-detail">
|
||||||
|
<div class="detail-header">
|
||||||
|
<div class="detail-avatar">{{ selectedPerson.name.charAt(0) }}</div>
|
||||||
|
<div class="detail-info">
|
||||||
|
<h4>{{ selectedPerson.name }}</h4>
|
||||||
|
<p>{{ selectedPerson.position }} - {{ selectedPerson.department }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-placeholder">
|
||||||
|
<p>详细数据面板</p>
|
||||||
|
<p class="placeholder-text">此处将显示选中人员的详细数据分析,包括业绩趋势、客户分析等信息</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps } from 'vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
selectedPerson: Object
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-card.detail-section {
|
||||||
|
grid-column: 3 / 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content .no-selection {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-selection .empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-detail .detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-avatar {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #2196F3;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-info h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-info p {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-placeholder {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
233
my-vue-app/src/views/topOne/components/DataTable.vue
Normal file
233
my-vue-app/src/views/topOne/components/DataTable.vue
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-card table-section">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>详细数据表格</h3>
|
||||||
|
</div>
|
||||||
|
<div class="data-table-container">
|
||||||
|
<!-- 筛选器 -->
|
||||||
|
<div class="table-filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>部门:</label>
|
||||||
|
<select v-model="filters.department">
|
||||||
|
<option value="">全部部门</option>
|
||||||
|
<option value="销售一部">销售一部</option>
|
||||||
|
<option value="销售二部">销售二部</option>
|
||||||
|
<option value="销售三部">销售三部</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>职位:</label>
|
||||||
|
<select v-model="filters.position">
|
||||||
|
<option value="">全部职位</option>
|
||||||
|
<option value="销售经理">销售经理</option>
|
||||||
|
<option value="销售专员">销售专员</option>
|
||||||
|
<option value="销售助理">销售助理</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>时间范围:</label>
|
||||||
|
<select v-model="filters.timeRange">
|
||||||
|
<option value="today">今日</option>
|
||||||
|
<option value="week">本周</option>
|
||||||
|
<option value="month">本月</option>
|
||||||
|
<option value="quarter">本季度</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>成交状态:</label>
|
||||||
|
<select v-model="filters.dealStatus">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="已成交">已成交</option>
|
||||||
|
<option value="跟进中">跟进中</option>
|
||||||
|
<option value="已失效">已失效</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据表格 -->
|
||||||
|
<div class="data-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>人员</th>
|
||||||
|
<th @click="$emit('sort-by', 'dealRate')" class="sortable">
|
||||||
|
成交率
|
||||||
|
<span class="sort-icon" :class="{ active: sortField === 'dealRate' }">
|
||||||
|
{{ sortOrder === 'desc' ? '↓' : '↑' }}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th @click="$emit('sort-by', 'callDuration')" class="sortable">
|
||||||
|
通话时长
|
||||||
|
<span class="sort-icon" :class="{ active: sortField === 'callDuration' }">
|
||||||
|
{{ sortOrder === 'desc' ? '↓' : '↑' }}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th>通话次数</th>
|
||||||
|
<th>成交金额</th>
|
||||||
|
<th>部门</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="person in filteredTableData" :key="person.id" @click="$emit('select-person', person)" :class="{ selected: selectedPerson && selectedPerson.id === person.id }">
|
||||||
|
<td>
|
||||||
|
<div class="person-info">
|
||||||
|
<div class="person-avatar">{{ person.name.charAt(0) }}</div>
|
||||||
|
<div>
|
||||||
|
<div class="person-name">{{ person.name }}</div>
|
||||||
|
<div class="person-position">{{ person.position }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="deal-rate">
|
||||||
|
<span class="rate-value" :class="getRateClass(person.dealRate)">{{ person.dealRate }}%</span>
|
||||||
|
<div class="rate-bar">
|
||||||
|
<div class="rate-fill" :style="{ width: person.dealRate + '%', backgroundColor: getRateColor(person.dealRate) }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDuration(person.callDuration) }}</td>
|
||||||
|
<td>{{ person.callCount }}次</td>
|
||||||
|
<td>¥{{ person.dealAmount.toLocaleString() }}</td>
|
||||||
|
<td>{{ person.department }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps, defineEmits, reactive } from 'vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
filteredTableData: Array,
|
||||||
|
selectedPerson: Object,
|
||||||
|
sortField: String,
|
||||||
|
sortOrder: String,
|
||||||
|
getRateClass: Function,
|
||||||
|
getRateColor: Function,
|
||||||
|
formatDuration: Function
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['sort-by', 'select-person']);
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
department: '',
|
||||||
|
position: '',
|
||||||
|
timeRange: 'month',
|
||||||
|
dealStatus: ''
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-card.table-section {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th, .data-table td {
|
||||||
|
padding: 12px 15px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-icon.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-position {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deal-rate .rate-value {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deal-rate .rate-bar {
|
||||||
|
height: 6px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deal-rate .rate-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr.selected {
|
||||||
|
background-color: #e8f5e9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
80
my-vue-app/src/views/topOne/components/FunnelChart.vue
Normal file
80
my-vue-app/src/views/topOne/components/FunnelChart.vue
Normal 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>
|
||||||
107
my-vue-app/src/views/topOne/components/KpiMetrics.vue
Normal file
107
my-vue-app/src/views/topOne/components/KpiMetrics.vue
Normal 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>
|
||||||
101
my-vue-app/src/views/topOne/components/QualityCalls.vue
Normal file
101
my-vue-app/src/views/topOne/components/QualityCalls.vue
Normal 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>
|
||||||
116
my-vue-app/src/views/topOne/components/RankingList.vue
Normal file
116
my-vue-app/src/views/topOne/components/RankingList.vue
Normal 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>
|
||||||
92
my-vue-app/src/views/topOne/components/SalesProgress.vue
Normal file
92
my-vue-app/src/views/topOne/components/SalesProgress.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>销售实时进度</h3>
|
||||||
|
<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>
|
||||||
120
my-vue-app/src/views/topOne/components/TaskList.vue
Normal file
120
my-vue-app/src/views/topOne/components/TaskList.vue
Normal 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>
|
||||||
1181
my-vue-app/src/views/topOne/components/secondTop.vue
Normal file
1181
my-vue-app/src/views/topOne/components/secondTop.vue
Normal file
File diff suppressed because it is too large
Load Diff
1516
my-vue-app/src/views/topOne/topone.vue
Normal file
1516
my-vue-app/src/views/topOne/topone.vue
Normal file
File diff suppressed because it is too large
Load Diff
16
my-vue-app/vite.config.js
Normal file
16
my-vue-app/vite.config.js
Normal 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"
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user