<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>
|
<!-- ✅ 新增短信图标 -->
|
<svg
|
v-else-if="action.icon === 'IconMessageSquare'"
|
viewBox="0 0 24 24"
|
fill="none"
|
stroke="currentColor"
|
stroke-width="2"
|
>
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
<line x1="8" y1="9" x2="16" y2="9" />
|
<line x1="8" y1="13" x2="14" y2="13" />
|
<line x1="8" y1="17" x2="12" y2="17" />
|
</svg>
|
</div>
|
<div class="action-label">{{ action.label }}</div>
|
</div>
|
</div>
|
</div>
|
</transition>
|
<!-- 短信发送对话框 -->
|
<el-dialog
|
title="短信发送"
|
:visible.sync="smsDialogVisible"
|
width="800px"
|
:close-on-click-modal="false"
|
append-to-body
|
>
|
<el-form ref="smsForm" :model="smsForm" label-width="100px">
|
<el-form-item label="患者名称">
|
<el-input v-model="smsForm.sendname"></el-input>
|
</el-form-item>
|
<el-form-item label="年龄">
|
<el-input v-model="smsForm.age"></el-input>
|
</el-form-item>
|
<el-form-item label="电话">
|
<el-input v-model="smsForm.telcode"></el-input>
|
</el-form-item>
|
<el-form-item label="科室">
|
<el-input v-model="smsForm.deptname"></el-input>
|
</el-form-item>
|
<el-form-item label="病区">
|
<el-input v-model="smsForm.leavehospitaldistrictname"></el-input>
|
</el-form-item>
|
<!-- 短信发送对话框 - 模板选择区域(增强版) -->
|
<el-form-item label="选择模板">
|
<el-row :gutter="10">
|
<el-col :span="7">
|
<el-select
|
v-model="templateFilterDept"
|
placeholder="按科室"
|
filterable
|
clearable
|
style="width: 100%"
|
@change="filterTemplates"
|
>
|
<el-option
|
v-for="dept in departmentOptions"
|
:key="dept.value"
|
:label="dept.label"
|
:value="dept.value"
|
/>
|
</el-select>
|
</el-col>
|
<el-col :span="7">
|
<el-select
|
v-model="templateFilterWard"
|
placeholder="按病区"
|
filterable
|
clearable
|
style="width: 100%"
|
@change="filterTemplates"
|
>
|
<el-option
|
v-for="ward in wardOptions"
|
:key="ward.value"
|
:label="ward.label"
|
:value="ward.value"
|
/>
|
</el-select>
|
</el-col>
|
<el-col :span="10">
|
<el-select
|
v-model="selectedTemplateId"
|
placeholder="请选择短信模板"
|
filterable
|
clearable
|
style="width: 100%"
|
@change="handleTemplateSelect"
|
>
|
<el-option
|
v-for="tmpl in filteredTemplateOptions"
|
:key="tmpl.templetid"
|
:label="`【${tmpl.templetno}】${tmpl.templetname}`"
|
:value="tmpl.templetid"
|
>
|
<span style="float: left">{{ tmpl.templetname }}</span>
|
<span style="float: right; color: #909399; font-size: 12px">
|
{{ tmpl.deptName || "通用" }} /
|
{{ tmpl.wardName || "全部" }}
|
</span>
|
</el-option>
|
</el-select>
|
</el-col>
|
<el-col :span="6">
|
<el-button
|
type="primary"
|
plain
|
icon="el-icon-plus"
|
@click="openQuickCreateTemplate"
|
>
|
新建
|
</el-button>
|
</el-col>
|
</el-row>
|
</el-form-item>
|
|
<el-form-item label="短信内容">
|
<el-input
|
type="textarea"
|
:rows="4"
|
v-model="smsContent"
|
placeholder="请输入短信内容..."
|
maxlength="500"
|
show-word-limit
|
></el-input>
|
</el-form-item>
|
</el-form>
|
<div slot="footer" class="dialog-footer">
|
<el-button @click="smsDialogVisible = false">取 消</el-button>
|
<el-button type="primary" @click="sendSms" :loading="smsLoading">
|
确认发送
|
</el-button>
|
</div>
|
<!-- 内嵌:快速新建模板对话框 -->
|
<!-- 快速新建模板对话框(增强版) -->
|
<el-dialog
|
title="新建短信模板"
|
:visible.sync="quickCreateVisible"
|
width="500px"
|
append-to-body
|
:close-on-click-modal="false"
|
>
|
<el-form
|
:model="quickTemplateForm"
|
:rules="quickTemplateRules"
|
ref="quickTemplateForm"
|
label-width="90px"
|
>
|
<el-form-item label="模板编号" prop="templetno">
|
<el-input
|
v-model="quickTemplateForm.templetno"
|
placeholder="请输入模板编号"
|
/>
|
</el-form-item>
|
<el-form-item label="模板名称" prop="templetname">
|
<el-input
|
v-model="quickTemplateForm.templetname"
|
placeholder="请输入模板名称"
|
/>
|
</el-form-item>
|
<el-form-item label="所属科室">
|
<el-select
|
v-model="quickTemplateForm.deptCode"
|
placeholder="请选择科室"
|
filterable
|
clearable
|
style="width: 100%"
|
@change="
|
(val) => {
|
const dept = departmentOptions.find((d) => d.value === val);
|
quickTemplateForm.deptName = dept ? dept.label : '';
|
}
|
"
|
>
|
<el-option
|
v-for="dept in departmentOptions"
|
:key="dept.value"
|
:label="dept.label"
|
:value="dept.value"
|
/>
|
</el-select>
|
</el-form-item>
|
<el-form-item label="所属病区">
|
<el-select
|
v-model="quickTemplateForm.wardCode"
|
placeholder="请选择病区"
|
filterable
|
clearable
|
style="width: 100%"
|
>
|
<el-option
|
v-for="ward in wardOptions"
|
:key="ward.value"
|
:label="ward.label"
|
:value="ward.value"
|
/>
|
</el-select>
|
</el-form-item>
|
<el-form-item label="模板内容" prop="templetcontent">
|
<el-input
|
v-model="quickTemplateForm.templetcontent"
|
type="textarea"
|
:rows="4"
|
placeholder="请输入短信模板内容"
|
maxlength="500"
|
show-word-limit
|
/>
|
</el-form-item>
|
</el-form>
|
<div slot="footer">
|
<el-button @click="quickCreateVisible = false">取 消</el-button>
|
<el-button
|
type="primary"
|
@click="submitQuickTemplate"
|
:loading="quickCreateLoading"
|
>
|
保存并使用
|
</el-button>
|
</div>
|
</el-dialog>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script>
|
import {
|
getCurrentUserServiceSubtaskCount,
|
sendMsg,
|
} from "@/api/AiCentre/index";
|
import {
|
listSmstemplet,
|
getSmstemplet,
|
addSmstemplet,
|
updateSmstemplet,
|
delSmstemplet,
|
} from "@/api/smartor/smstemplet";
|
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,
|
templateOptions: [], // 模板下拉列表
|
selectedTemplateId: "", // 选中的模板ID
|
templateLoading: false, // 模板列表加载状态
|
// 模板筛选
|
templateFilterDept: "",
|
templateFilterWard: "", // 新增:病区筛选
|
filteredTemplateOptions: [],
|
|
// 科室选项(从 Vuex 获取)
|
departmentOptions: [],
|
// 新增:病区选项(从 Vuex 获取)
|
wardOptions: [],
|
// 快速新建模板
|
quickCreateVisible: false,
|
quickCreateLoading: false,
|
quickTemplateForm: {
|
templetno: "",
|
templetname: "",
|
templetcontent: "",
|
},
|
quickTemplateRules: {
|
templetname: [
|
{ required: true, message: "请输入模板名称", trigger: "blur" },
|
],
|
templetcontent: [
|
{ required: true, message: "请输入模板内容", trigger: "blur" },
|
],
|
},
|
// 短信发送对话框
|
// smsDialogVisible: false,
|
smsLoading: false, // ✅ 新增加载状态
|
smsContent: "",
|
smsForm: {
|
sendname: "",
|
age: "",
|
telcode: "",
|
deptname: "",
|
leavehospitaldistrictname: "",
|
},
|
// 统计数据
|
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: "sendSms", // ✅ 新增短信发送
|
label: "短信发送",
|
icon: "IconMessageSquare",
|
action: "openSmsDialog", // 标记为弹框操作
|
},
|
],
|
};
|
},
|
|
computed: {
|
totalUnread() {
|
return this.statsItems.reduce((sum, item) => sum + item.unread, 0);
|
},
|
// ✅ Vuex 双向绑定
|
smsDialogVisible: {
|
get() {
|
return this.$store.state.sms.smsDialogVisible;
|
},
|
set(val) {
|
if (!val) {
|
this.$store.dispatch("sms/closeSmsDialog");
|
}
|
},
|
},
|
},
|
watch: {
|
// ✅ 监听 Vuex 对话框状态
|
"$store.state.sms.smsDialogVisible": {
|
handler(val) {
|
if (val) {
|
const patientData = this.$store.state.sms.patientData;
|
this.smsForm = { ...patientData };
|
this.smsContent = this.$store.state.sms.smsTemplate || "";
|
|
// 展开悬浮球
|
if (!this.isExpanded) {
|
this.isExpanded = true;
|
this.isHidden = false;
|
clearTimeout(this.hideTimer);
|
}
|
|
// ★★★ 关键修复:在这里初始化模板相关数据 ★★★
|
this.initTemplateData();
|
}
|
},
|
immediate: false,
|
},
|
},
|
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: {
|
initTemplateData() {
|
// 初始化科室选项
|
if (this.$store.getters.belongDepts) {
|
this.departmentOptions = this.$store.getters.belongDepts.map(
|
(dept) => ({
|
label: dept.deptName,
|
value: dept.deptCode,
|
})
|
);
|
}
|
|
// ★★★ 新增:初始化病区选项 ★★★
|
if (this.$store.getters.belongWards) {
|
this.wardOptions = this.$store.getters.belongWards.map((ward) => ({
|
label: ward.districtName,
|
value: ward.districtCode,
|
}));
|
}
|
|
// 重置筛选
|
this.templateFilterDept = "";
|
this.templateFilterWard = ""; // 新增
|
this.selectedTemplateId = "";
|
|
// 加载模板列表
|
this.loadTemplates();
|
},
|
toggleExpand() {
|
this.isExpanded = !this.isExpanded;
|
if (this.isExpanded) {
|
this.isHidden = false;
|
clearTimeout(this.hideTimer);
|
this.updateStats();
|
}
|
},
|
async loadTemplates() {
|
this.templateLoading = true;
|
try {
|
// const { listSmstemplet } = await import("@/api/smartor/smstemplet");
|
const res = await listSmstemplet({ pageNum: 1, pageSize: 999 });
|
this.templateOptions = res.rows || [];
|
this.filterTemplates(); // 应用筛选
|
} catch (error) {
|
console.error("加载短信模板失败:", error);
|
this.templateOptions = [];
|
this.filteredTemplateOptions = [];
|
} finally {
|
this.templateLoading = false;
|
}
|
},
|
handleTemplateSelect(templateId) {
|
if (!templateId) {
|
this.smsContent = "";
|
return;
|
}
|
const selected = this.templateOptions.find(
|
(t) => t.templetid === templateId
|
);
|
if (selected) {
|
this.smsContent = selected.templetcontent || "";
|
}
|
},
|
openQuickCreateTemplate() {
|
this.quickTemplateForm = {
|
templetno: "",
|
templetname: "",
|
templetcontent: "",
|
};
|
this.quickCreateVisible = true;
|
this.$nextTick(() => {
|
if (this.$refs.quickTemplateForm) {
|
this.$refs.quickTemplateForm.clearValidate();
|
}
|
});
|
},
|
/**
|
* 提交快速新建模板
|
*/
|
async submitQuickTemplate() {
|
this.$refs.quickTemplateForm.validate(async (valid) => {
|
if (!valid) return;
|
|
this.quickCreateLoading = true;
|
try {
|
// const { addSmstemplet } = await import("@/api/smartor/smstemplet");
|
const res = await addSmstemplet(this.quickTemplateForm);
|
|
if (res.code === 200) {
|
this.$modal.msgSuccess("模板创建成功");
|
|
// 刷新模板列表
|
await this.loadTemplates();
|
|
// 自动选中刚创建的模板
|
const newTmpl = this.templateOptions.find(
|
(t) => t.templetname === this.quickTemplateForm.templetname
|
);
|
if (newTmpl) {
|
this.selectedTemplateId = newTmpl.templetid;
|
this.smsContent = newTmpl.templetcontent;
|
}
|
|
this.quickCreateVisible = false;
|
} else {
|
this.$modal.msgError(res.msg || "创建失败");
|
}
|
} catch (error) {
|
console.error("创建模板失败:", error);
|
this.$modal.msgError("创建失败,请稍后重试");
|
} finally {
|
this.quickCreateLoading = false;
|
}
|
});
|
},
|
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(action);
|
|
if (action.action === "openSmsDialog") {
|
this.openSmsDialog();
|
return;
|
}
|
|
// 原有逻辑保持不变
|
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("非管理员用户暂无创建任务权限");
|
}
|
},
|
// 打开短信发送对话框
|
/**
|
* 改造原有的 openSmsDialog 方法
|
*/
|
openSmsDialog(patientData = {}) {
|
// 重置筛选
|
this.templateFilterDept = "";
|
|
// 重置选择
|
this.selectedTemplateId = "";
|
this.smsContent = patientData.smsTemplate || "";
|
|
// 加载模板列表
|
this.loadTemplates();
|
|
// 打开对话框(通过 Vuex)
|
this.$store.dispatch("sms/openSmsDialog", {
|
name: patientData.name || "",
|
age: patientData.age || "",
|
phone: patientData.phone || "",
|
deptName: patientData.deptName || "",
|
wardName: patientData.wardName || "",
|
smsTemplate: patientData.smsTemplate || "",
|
});
|
},
|
// 新增:筛选模板
|
filterTemplates() {
|
let filtered = [...this.templateOptions];
|
|
// 按科室筛选
|
if (this.templateFilterDept) {
|
filtered = filtered.filter(
|
(tmpl) => tmpl.deptCode === this.templateFilterDept || !tmpl.deptCode
|
);
|
}
|
|
// ★★★ 新增:按病区筛选 ★★★
|
if (this.templateFilterWard) {
|
filtered = filtered.filter(
|
(tmpl) => tmpl.wardCode === this.templateFilterWard || !tmpl.wardCode
|
);
|
}
|
|
this.filteredTemplateOptions = filtered;
|
|
// 清空已选
|
this.selectedTemplateId = "";
|
this.smsContent = "";
|
},
|
// 发送短信
|
async sendSms() {
|
if (!this.smsContent.trim()) {
|
this.$modal.msgError("请输入短信内容");
|
return;
|
}
|
|
if (!this.smsForm.telcode) {
|
this.$modal.msgError("患者电话不能为空");
|
return;
|
}
|
|
this.smsLoading = true;
|
try {
|
const res = await sendMsg({
|
phone: this.smsForm.telcode,
|
content: this.smsContent,
|
});
|
|
if (res.code === 200) {
|
this.$modal.msgSuccess("短信发送成功");
|
// ✅ 通过 Vuex 关闭对话框
|
this.$store.dispatch("sms/closeSmsDialog");
|
this.smsContent = "";
|
} else {
|
this.$modal.msgError(res.msg || "发送失败");
|
}
|
} catch (error) {
|
console.error("发送短信失败:", error);
|
this.$modal.msgError("发送失败,请稍后重试");
|
} finally {
|
this.smsLoading = false;
|
this.selectedTemplateId = "";
|
}
|
},
|
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>
|