<template>
|
<div
|
ref="floatBall"
|
class="float-ball"
|
:class="{
|
'float-ball-hidden': isHidden && !isHovering,
|
'float-ball-expanded': isExpanded,
|
}"
|
:style="{
|
left: position.x + 'px',
|
top: position.y + 'px',
|
'--primary-color': primaryColor,
|
'--hover-color': hoverColor,
|
}"
|
@mouseenter="handleMouseEnter"
|
@mouseleave="handleMouseLeave"
|
>
|
<!-- 主球体 -->
|
<div
|
class="ball-main"
|
:class="{ 'ball-main-expanded': isExpanded }"
|
@click="toggleExpand"
|
@mousedown="startDrag"
|
@touchstart="startDrag"
|
>
|
<!-- 折叠状态图标 -->
|
<div v-if="!isExpanded" class="ball-icon">
|
<svg
|
class="fold-icon"
|
viewBox="0 0 24 24"
|
fill="none"
|
stroke="currentColor"
|
stroke-width="2"
|
>
|
<path d="M4 6h16M4 12h16M4 18h16" />
|
</svg>
|
</div>
|
|
<!-- 展开状态关闭按钮 -->
|
<div v-else class="close-btn" @click.stop="toggleExpand">
|
<svg
|
class="close-icon"
|
viewBox="0 0 24 24"
|
fill="none"
|
stroke="currentColor"
|
stroke-width="2"
|
>
|
<path d="M6 18L18 6M6 6l12 12" />
|
</svg>
|
</div>
|
|
<!-- 角标提示(有未读数时显示) -->
|
<div v-if="totalUnread > 0" class="ball-badge">
|
{{ totalUnread > 99 ? "99+" : totalUnread }}
|
</div>
|
</div>
|
|
<!-- 展开的内容面板 -->
|
<transition name="ball-expand">
|
<div v-if="isExpanded" class="ball-content">
|
<div class="content-header">
|
<h3>随访工作台</h3>
|
<div class="update-time">更新于 {{ updateTime }}</div>
|
</div>
|
|
<div class="stats-grid">
|
<div
|
v-for="(item, index) in statsItems"
|
:key="index"
|
class="stat-item"
|
:class="{ 'stat-item-highlight': item.highlight }"
|
@click="handleItemClick(item)"
|
>
|
<div class="stat-icon">
|
<svg
|
v-if="item.icon === 'IconUsers'"
|
viewBox="0 0 24 24"
|
fill="none"
|
stroke="currentColor"
|
stroke-width="2"
|
>
|
<path
|
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13-7.157a4 4 0 11-8 0 4 4 0 018 0z"
|
/>
|
</svg>
|
<svg
|
v-else-if="item.icon === 'IconAlertCircle'"
|
viewBox="0 0 24 24"
|
fill="none"
|
stroke="currentColor"
|
stroke-width="2"
|
>
|
<path d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
</svg>
|
<svg
|
v-else-if="item.icon === 'IconTask'"
|
viewBox="0 0 24 24"
|
fill="none"
|
stroke="currentColor"
|
stroke-width="2"
|
>
|
<path
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
/>
|
</svg>
|
</div>
|
<div class="stat-info">
|
<div class="stat-label">{{ item.label }}</div>
|
<div class="stat-value">{{ item.value }}</div>
|
<div
|
v-if="item.trend"
|
class="stat-trend"
|
:class="'trend-' + item.trend.type"
|
>
|
<span class="trend-arrow">{{ item.trend.arrow }}</span>
|
<span class="trend-value">{{ item.trend.value }}</span>
|
</div>
|
</div>
|
<div v-if="item.unread > 0" class="stat-badge">
|
{{ item.unread > 99 ? "99+" : item.unread }}
|
</div>
|
</div>
|
</div>
|
|
<div class="quick-actions">
|
<div
|
v-for="(action, index) in quickActions"
|
:key="index"
|
class="action-item"
|
@click="handleActionClick(action)"
|
>
|
<div class="action-icon">
|
<svg
|
v-if="action.icon === 'IconMessageCircle'"
|
viewBox="0 0 24 24"
|
fill="none"
|
stroke="currentColor"
|
stroke-width="2"
|
>
|
<path
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
/>
|
</svg>
|
<svg
|
v-else-if="action.icon === 'IconPhone'"
|
viewBox="0 0 24 24"
|
fill="none"
|
stroke="currentColor"
|
stroke-width="2"
|
>
|
<path
|
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
/>
|
</svg>
|
</div>
|
<div class="action-label">{{ action.label }}</div>
|
</div>
|
</div>
|
</div>
|
</transition>
|
</div>
|
</template>
|
|
<script>
|
import { getCurrentUserServiceSubtaskCount } from "@/api/AiCentre/index";
|
export default {
|
name: "FloatBall",
|
|
props: {
|
// 初始位置
|
initialPosition: {
|
type: Object,
|
default: () => ({ x: 20, y: 100 }),
|
},
|
// 是否自动隐藏
|
autoHide: {
|
type: Boolean,
|
default: true,
|
},
|
// 隐藏延迟(毫秒)
|
hideDelay: {
|
type: Number,
|
default: 2000,
|
},
|
// 主题颜色
|
primaryColor: {
|
type: String,
|
default: "#4f46e5",
|
},
|
// 悬停颜色
|
hoverColor: {
|
type: String,
|
default: "#4338ca",
|
},
|
// 数据源(可从外部传入)
|
statsData: {
|
type: Object,
|
default: null,
|
},
|
},
|
|
data() {
|
return {
|
isExpanded: false,
|
isHovering: false,
|
isHidden: false,
|
isDragging: false,
|
position: { ...this.initialPosition },
|
dragStart: { x: 0, y: 0 },
|
hideTimer: null,
|
updateTime: "",
|
roles: null,
|
// 统计数据
|
statsItems: [
|
{
|
id: "pending",
|
label: "待随访",
|
value: "0",
|
unread: 0,
|
urltype: 2,
|
icon: "IconUsers",
|
url: "/followvisit/discharge",
|
trend: { type: "up", arrow: "", value: "" },
|
highlight: true,
|
},
|
{
|
id: "failed",
|
label: "随访失败",
|
value: "0",
|
unread: 0,
|
urltype: 3,
|
icon: "IconAlertCircle",
|
url: "/followvisit/discharge",
|
trend: { type: "down", arrow: "", value: "" },
|
},
|
{
|
id: "abnormal",
|
label: "任务异常",
|
value: "0",
|
unread: 0,
|
urltype: 4,
|
icon: "IconAlertCircle",
|
url: "/followvisit/discharge",
|
trend: { type: "up", arrow: "", value: "" },
|
},
|
{
|
id: "myTasks",
|
label: "我的任务",
|
value: "0",
|
unread: 0,
|
urltype: 5,
|
icon: "IconTask",
|
url: "/followvisit/discharge",
|
trend: { type: "stable", arrow: "", value: "" },
|
},
|
],
|
|
// 快捷操作
|
quickActions: [
|
{
|
id: "sms",
|
label: "创建问卷任务",
|
icon: "IconMessageCircle",
|
url: "/followvisit/QuestionnaireTask?type=2&serviceType=2",
|
},
|
{
|
id: "call",
|
label: "创建语音任务",
|
icon: "IconPhone",
|
url: "/followvisit/particty?type=1&serviceType=2",
|
},
|
// {
|
// id: 'chat',
|
// label: '在线聊天',
|
// icon: 'IconMessageCircle',
|
// url: '/chat'
|
// }
|
],
|
};
|
},
|
|
computed: {
|
totalUnread() {
|
return this.statsItems.reduce((sum, item) => sum + item.unread, 0);
|
},
|
},
|
|
mounted() {
|
this.roles = this.$store.state.user.roles;
|
this.loadPosition();
|
|
if (this.autoHide) {
|
this.startAutoHide();
|
}
|
|
// 点击外部关闭
|
document.addEventListener("click", this.handleClickOutside);
|
|
// 窗口大小变化时重新定位
|
window.addEventListener("resize", this.handleResize);
|
},
|
|
beforeDestroy() {
|
document.removeEventListener("click", this.handleClickOutside);
|
window.removeEventListener("resize", this.handleResize);
|
clearTimeout(this.hideTimer);
|
},
|
|
methods: {
|
toggleExpand() {
|
this.isExpanded = !this.isExpanded;
|
if (this.isExpanded) {
|
this.isHidden = false;
|
clearTimeout(this.hideTimer);
|
this.updateStats();
|
}
|
},
|
|
handleMouseEnter() {
|
this.isHovering = true;
|
if (this.autoHide) {
|
clearTimeout(this.hideTimer);
|
this.isHidden = false;
|
}
|
},
|
|
handleMouseLeave() {
|
this.isHovering = false;
|
if (this.autoHide && !this.isExpanded) {
|
this.startAutoHide();
|
}
|
},
|
|
startAutoHide() {
|
this.hideTimer = setTimeout(() => {
|
if (!this.isExpanded && !this.isHovering) {
|
this.isHidden = true;
|
}
|
}, this.hideDelay);
|
},
|
|
startDrag(e) {
|
e.preventDefault();
|
e.stopPropagation();
|
this.isDragging = true;
|
|
const clientX = e.type.includes("touch")
|
? e.touches[0].clientX
|
: e.clientX;
|
const clientY = e.type.includes("touch")
|
? e.touches[0].clientY
|
: e.clientY;
|
|
this.dragStart = {
|
x: clientX - this.position.x,
|
y: clientY - this.position.y,
|
};
|
|
const onMove = (moveEvent) => {
|
if (!this.isDragging) return;
|
|
const moveX = moveEvent.type.includes("touch")
|
? moveEvent.touches[0].clientX
|
: moveEvent.clientX;
|
const moveY = moveEvent.type.includes("touch")
|
? moveEvent.touches[0].clientY
|
: moveEvent.clientY;
|
|
const newX = moveX - this.dragStart.x;
|
const newY = moveY - this.dragStart.y;
|
|
// 边界检查
|
const maxX = window.innerWidth - 60;
|
const maxY = window.innerHeight - 60;
|
|
this.position.x = Math.max(0, Math.min(newX, maxX));
|
this.position.y = Math.max(0, Math.min(newY, maxY));
|
};
|
|
const onEnd = () => {
|
this.isDragging = false;
|
document.removeEventListener("mousemove", onMove);
|
document.removeEventListener("mouseup", onEnd);
|
document.removeEventListener("touchmove", onMove);
|
document.removeEventListener("touchend", onEnd);
|
|
// 如果靠近边缘,自动吸附
|
if (this.position.x < 20) {
|
this.position.x = 0;
|
} else if (this.position.x > window.innerWidth - 80) {
|
this.position.x = window.innerWidth - 60;
|
}
|
|
// 保存位置到本地存储
|
try {
|
localStorage.setItem(
|
"floatBallPosition",
|
JSON.stringify(this.position)
|
);
|
} catch (e) {
|
console.error("保存位置失败:", e);
|
}
|
};
|
|
document.addEventListener("mousemove", onMove);
|
document.addEventListener("mouseup", onEnd);
|
document.addEventListener("touchmove", onMove, { passive: false });
|
document.addEventListener("touchend", onEnd);
|
},
|
|
handleItemClick(item) {
|
if (item.url) {
|
console.log(item.url, "item.url");
|
|
// this.$router.push(item.url);
|
this.$router.replace({
|
path: item.url,
|
query: {
|
errtype: item.urltype,
|
},
|
});
|
this.toggleExpand();
|
}
|
},
|
|
handleActionClick(action) {
|
console.log(this.roles, "this.roles");
|
if (
|
action.url &&
|
(this.roles.includes("admin") || this.roles.includes("sysadmin"))
|
) {
|
this.$router.replace(action.url);
|
this.toggleExpand();
|
} else {
|
this.$modal.msgError("非管理员用户暂无创建任务权限");
|
}
|
},
|
|
async updateStats() {
|
try {
|
// 这里可以替换为实际的 API 调用
|
// const response = await this.$api.getFollowupStats()
|
// this.statsItems = response.data
|
|
// 模拟数据更新
|
const mockData = {
|
pending: {
|
value: "128",
|
unread: null,
|
trend: { type: "up", arrow: "↑", value: "5" },
|
},
|
failed: {
|
value: "24",
|
unread: null,
|
trend: { type: "down", arrow: "↓", value: "2" },
|
},
|
abnormal: {
|
value: "8",
|
unread: null,
|
trend: { type: "up", arrow: "↑", value: "3" },
|
},
|
myTasks: {
|
value: "156",
|
unread: null,
|
trend: { type: "stable", arrow: "→", value: "0" },
|
},
|
};
|
const response = await getCurrentUserServiceSubtaskCount();
|
mockData.pending.value = response.pendingVisitCount;
|
mockData.failed.value = response.failedVisitCount;
|
mockData.abnormal.value = response.abnormalVisitCount;
|
mockData.myTasks.value = response.allVisitCount;
|
this.statsItems = this.statsItems.map((item) => {
|
const data = mockData[item.id] || {};
|
return {
|
...item,
|
value: data.value || item.value,
|
unread: data.unread || item.unread,
|
trend: data.trend || item.trend,
|
};
|
});
|
|
// 更新时间
|
const now = new Date();
|
this.updateTime = `${now.getHours().toString().padStart(2, "0")}:${now
|
.getMinutes()
|
.toString()
|
.padStart(2, "0")}`;
|
} catch (error) {
|
console.error("更新统计数据失败:", error);
|
}
|
},
|
|
loadPosition() {
|
try {
|
const savedPosition = localStorage.getItem("floatBallPosition");
|
if (savedPosition) {
|
const parsed = JSON.parse(savedPosition);
|
this.position = parsed;
|
}
|
} catch (e) {
|
console.error("加载位置失败:", e);
|
}
|
},
|
|
handleClickOutside(e) {
|
if (
|
this.isExpanded &&
|
this.$refs.floatBall &&
|
!this.$refs.floatBall.contains(e.target)
|
) {
|
this.toggleExpand();
|
}
|
},
|
|
handleResize() {
|
const maxX = window.innerWidth - 60;
|
const maxY = window.innerHeight - 60;
|
|
this.position.x = Math.min(this.position.x, maxX);
|
this.position.y = Math.min(this.position.y, maxY);
|
},
|
},
|
};
|
</script>
|
|
<style scoped>
|
.float-ball {
|
position: fixed;
|
z-index: 9999;
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
pointer-events: auto;
|
}
|
|
.float-ball-hidden {
|
opacity: 0.3;
|
transform: translateX(10px);
|
}
|
|
.float-ball-hidden:hover {
|
opacity: 1;
|
transform: translateX(0);
|
}
|
|
.ball-main {
|
width: 60px;
|
height: 60px;
|
border-radius: 50%;
|
background: linear-gradient(135deg, var(--primary-color), #7c3aed);
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
cursor: move;
|
box-shadow: 0 4px 20px rgba(79, 70, 229, 0.3);
|
transition: all 0.3s ease;
|
position: relative;
|
z-index: 10000;
|
}
|
|
.ball-main:hover {
|
background: linear-gradient(135deg, var(--hover-color), #6d28d9);
|
box-shadow: 0 6px 25px rgba(79, 70, 229, 0.4);
|
transform: scale(1.05);
|
}
|
|
.ball-main-expanded {
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
}
|
|
.ball-icon {
|
width: 24px;
|
height: 24px;
|
color: white;
|
}
|
|
.fold-icon {
|
width: 100%;
|
height: 100%;
|
}
|
|
.close-btn {
|
width: 24px;
|
height: 24px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
cursor: pointer;
|
color: white;
|
transition: transform 0.2s ease;
|
}
|
|
.close-btn:hover {
|
transform: rotate(90deg);
|
}
|
|
.close-icon {
|
width: 20px;
|
height: 20px;
|
}
|
|
.ball-badge {
|
position: absolute;
|
top: -5px;
|
right: -5px;
|
min-width: 20px;
|
height: 20px;
|
padding: 0 6px;
|
background: #ef4444;
|
color: white;
|
font-size: 12px;
|
font-weight: 600;
|
border-radius: 10px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
border: 2px solid white;
|
animation: pulse 2s infinite;
|
}
|
|
@keyframes pulse {
|
0%,
|
100% {
|
transform: scale(1);
|
}
|
50% {
|
transform: scale(1.1);
|
}
|
}
|
|
.ball-content {
|
position: absolute;
|
top: 70px;
|
left: 0;
|
width: 320px;
|
background: white;
|
border-radius: 16px;
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
overflow: hidden;
|
z-index: 9999;
|
}
|
|
.ball-expand-enter-active,
|
.ball-expand-leave-active {
|
transition: all 0.3s ease;
|
}
|
|
.ball-expand-enter,
|
.ball-expand-leave-to {
|
opacity: 0;
|
transform: translateY(-10px);
|
}
|
|
.content-header {
|
padding: 20px 20px 16px;
|
background: linear-gradient(135deg, var(--primary-color), #7c3aed);
|
color: white;
|
}
|
|
.content-header h3 {
|
margin: 0 0 8px 0;
|
font-size: 18px;
|
font-weight: 600;
|
}
|
|
.update-time {
|
font-size: 12px;
|
opacity: 0.9;
|
}
|
|
.stats-grid {
|
padding: 16px;
|
display: grid;
|
grid-template-columns: 1fr 1fr;
|
gap: 12px;
|
}
|
|
.stat-item {
|
padding: 16px;
|
background: #f8fafc;
|
border-radius: 12px;
|
cursor: pointer;
|
transition: all 0.2s ease;
|
position: relative;
|
border: 2px solid transparent;
|
}
|
|
.stat-item:hover {
|
background: #f1f5f9;
|
border-color: #e2e8f0;
|
transform: translateY(-2px);
|
}
|
|
.stat-item-highlight {
|
border-color: var(--primary-color);
|
background: linear-gradient(to bottom right, #f0f9ff, #f8fafc);
|
}
|
|
.stat-icon {
|
width: 32px;
|
height: 32px;
|
background: white;
|
border-radius: 8px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-bottom: 12px;
|
color: var(--primary-color);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
}
|
|
.stat-icon svg {
|
width: 18px;
|
height: 18px;
|
}
|
|
.stat-label {
|
font-size: 12px;
|
color: #64748b;
|
margin-bottom: 4px;
|
}
|
|
.stat-value {
|
font-size: 20px;
|
font-weight: 700;
|
color: #1e293b;
|
margin-bottom: 4px;
|
}
|
|
.stat-trend {
|
font-size: 11px;
|
display: flex;
|
align-items: center;
|
gap: 2px;
|
}
|
|
.trend-up {
|
color: #10b981;
|
}
|
|
.trend-down {
|
color: #ef4444;
|
}
|
|
.trend-stable {
|
color: #64748b;
|
}
|
|
.trend-arrow {
|
font-size: 10px;
|
}
|
|
.stat-badge {
|
position: absolute;
|
top: 12px;
|
right: 12px;
|
min-width: 18px;
|
height: 18px;
|
padding: 0 4px;
|
background: #ef4444;
|
color: white;
|
font-size: 10px;
|
font-weight: 600;
|
border-radius: 9px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
|
.quick-actions {
|
padding: 12px 20px 20px;
|
border-top: 1px solid #f1f5f9;
|
display: flex;
|
gap: 12px;
|
justify-content: center;
|
}
|
|
.action-item {
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
gap: 8px;
|
cursor: pointer;
|
padding: 12px;
|
border-radius: 8px;
|
transition: all 0.2s ease;
|
flex: 1;
|
}
|
|
.action-item:hover {
|
background: #f8fafc;
|
}
|
|
.action-icon {
|
width: 24px;
|
height: 24px;
|
color: var(--primary-color);
|
}
|
|
.action-icon svg {
|
width: 20px;
|
height: 20px;
|
}
|
|
.action-label {
|
font-size: 12px;
|
color: #475569;
|
font-weight: 500;
|
}
|
</style>
|