<template>
|
<div class="ptb-root">
|
<!-- 收起态:右下角小胶囊 -->
|
<button
|
v-if="collapsed"
|
class="ptb-pill"
|
type="button"
|
title="展开待处理任务"
|
@click="toggle"
|
>
|
<span class="ptb-pill-dot"></span>
|
<span class="ptb-pill-text">待处理</span>
|
<span class="ptb-pill-num">{{ total }}</span>
|
</button>
|
|
<!-- 展开态:当前登录人 + 近 4 个月出院/门诊随访待处理 -->
|
<div v-else class="ptb-card">
|
<div class="ptb-header">
|
<div class="ptb-header-left">
|
<svg
|
class="ptb-header-icon"
|
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-2" />
|
<path d="M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
<path d="M9 14l2 2 4-4" />
|
</svg>
|
<span class="ptb-header-title" title="出院满 7 天、仍待随访的出院/门诊任务">
|
待处理随访任务
|
</span>
|
</div>
|
<div class="ptb-header-actions">
|
<span
|
class="ptb-icon-btn"
|
:class="{ 'is-loading': loading }"
|
title="刷新"
|
@click="load"
|
>↻</span>
|
<span class="ptb-icon-btn" title="收起" @click="toggle">–</span>
|
</div>
|
</div>
|
|
<div class="ptb-body">
|
<div class="ptb-user">
|
<div class="ptb-avatar">{{ userInitial }}</div>
|
<div class="ptb-user-meta">
|
<div class="ptb-user-name" :title="displayName">{{ displayName }}</div>
|
<div class="ptb-user-scope" :title="scopeText">{{ scopeText }}</div>
|
</div>
|
</div>
|
|
<div class="ptb-months">
|
<div
|
v-for="row in monthRows"
|
:key="row.key"
|
class="ptb-month-row"
|
>
|
<span class="ptb-month-label">{{ row.label }}</span>
|
<span
|
class="ptb-month-seg is-clickable"
|
title="点击查看该月待随访患者"
|
@click="goToTasks(row, 2)"
|
>出院随访待处理<b class="ptb-num is-discharge">{{ row.dischargeCount }}</b>个</span>
|
<span class="ptb-month-comma">,</span>
|
<span
|
class="ptb-month-seg is-clickable"
|
title="点击查看该月待随访患者"
|
@click="goToTasks(row, 3)"
|
>门诊随访待处理<b class="ptb-num is-outpatient">{{ row.outpatientCount }}</b>个</span>
|
</div>
|
</div>
|
|
<div v-if="errorMsg" class="ptb-error">{{ errorMsg }}</div>
|
</div>
|
|
<div class="ptb-footer">
|
<span class="ptb-update-time">更新于 {{ updateTime || '--:--' }}</span>
|
<span class="ptb-footer-refresh" @click="load">刷新</span>
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<script>
|
import { getPendingTaskMonthStats } from '@/api/smartor/pendingTask'
|
import { getToken } from '@/utils/auth'
|
|
const COLLAPSE_KEY = 'pendingTaskBoxCollapsed'
|
const REFRESH_INTERVAL = 5 * 60 * 1000
|
// 固定展示:当月 + 往前 3 个月
|
const MONTH_COUNT = 4
|
|
export default {
|
name: 'PendingTaskBox',
|
data() {
|
return {
|
collapsed: localStorage.getItem(COLLAPSE_KEY) === '1',
|
loading: false,
|
errorMsg: '',
|
userName: '',
|
nickName: '',
|
total: 0,
|
months: [],
|
updateTime: '',
|
timer: null,
|
retryTimer: null,
|
retryCount: 0,
|
lastLoadAt: 0
|
}
|
},
|
computed: {
|
displayName() {
|
return (
|
this.nickName ||
|
this.$store.getters.nickName ||
|
this.userName ||
|
this.$store.getters.name ||
|
'未登录'
|
)
|
},
|
userInitial() {
|
const name = this.displayName
|
return name ? name.slice(0, 1) : '-'
|
},
|
scopeText() {
|
const depts = (this.$store.getters.belongDepts || [])
|
.map(item => item.deptName)
|
.filter(Boolean)
|
const wards = (this.$store.getters.belongWards || [])
|
.map(item => item.districtName)
|
.filter(Boolean)
|
const scope = wards.length ? wards : depts
|
if (!scope.length) {
|
return '当前登录人'
|
}
|
return scope.length > 2 ? scope.slice(0, 2).join('、') + ' 等' : scope.join('、')
|
},
|
monthMap() {
|
const map = {}
|
this.months.forEach(item => {
|
if (item && item.month) {
|
map[item.month] = {
|
dischargeCount: Number(item.dischargeCount) || 0,
|
outpatientCount: Number(item.outpatientCount) || 0
|
}
|
}
|
})
|
return map
|
},
|
// 当月 + 前 3 个月,没有数据的月份补 0
|
monthRows() {
|
const rows = []
|
const now = new Date()
|
for (let i = 0; i < MONTH_COUNT; i++) {
|
const date = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
const key = date.getFullYear() + '-' + month
|
const data = this.monthMap[key] || { dischargeCount: 0, outpatientCount: 0 }
|
rows.push({
|
key,
|
label: date.getFullYear() + '年' + month + '月',
|
dischargeCount: data.dischargeCount,
|
outpatientCount: data.outpatientCount
|
})
|
}
|
return rows
|
}
|
},
|
mounted() {
|
this.load()
|
this.timer = setInterval(() => this.load(), REFRESH_INTERVAL)
|
document.addEventListener('visibilitychange', this.handleVisible)
|
},
|
beforeDestroy() {
|
clearInterval(this.timer)
|
this.clearRetry()
|
document.removeEventListener('visibilitychange', this.handleVisible)
|
},
|
methods: {
|
toggle() {
|
this.collapsed = !this.collapsed
|
localStorage.setItem(COLLAPSE_KEY, this.collapsed ? '1' : '0')
|
if (!this.collapsed && Date.now() - this.lastLoadAt > 60 * 1000) {
|
this.load()
|
}
|
},
|
handleVisible() {
|
if (document.visibilityState === 'visible' && Date.now() - this.lastLoadAt > 60 * 1000) {
|
this.load()
|
}
|
},
|
async load() {
|
if (this.loading) {
|
return
|
}
|
if (!getToken()) {
|
// 刚刷新页面时登录态可能还没就绪,稍后重试
|
this.scheduleRetry(4000)
|
return
|
}
|
this.loading = true
|
try {
|
const res = await getPendingTaskMonthStats()
|
const payload = res && res.data && res.data.months ? res.data : res || {}
|
this.nickName = payload.nickName || this.nickName
|
this.userName = payload.userName || this.userName
|
this.months = Array.isArray(payload.months) ? payload.months : []
|
const sum = this.months.reduce((acc, item) => {
|
return acc + (Number(item.dischargeCount) || 0) + (Number(item.outpatientCount) || 0)
|
}, 0)
|
this.total = Number(payload.total) || sum
|
this.errorMsg = ''
|
this.updateTime = this.formatTime(new Date())
|
this.retryCount = 0
|
this.clearRetry()
|
} catch (error) {
|
// 后端重启/网络抖动时自动重试,不用等 5 分钟
|
this.errorMsg = '统计接口暂不可用,稍后自动重试'
|
this.scheduleRetry(20000)
|
} finally {
|
this.lastLoadAt = Date.now()
|
this.loading = false
|
}
|
},
|
scheduleRetry(delay) {
|
if (this.retryCount >= 5) {
|
return
|
}
|
this.retryCount += 1
|
this.clearRetry()
|
this.retryTimer = setTimeout(() => this.load(), delay)
|
},
|
clearRetry() {
|
if (this.retryTimer) {
|
clearTimeout(this.retryTimer)
|
this.retryTimer = null
|
}
|
},
|
formatTime(date) {
|
const pad = value => String(value).padStart(2, '0')
|
return pad(date.getHours()) + ':' + pad(date.getMinutes())
|
},
|
// 点击某个月的数字,跳到随访列表查看对应的待随访患者
|
goToTasks(row, serviceType) {
|
const path =
|
serviceType === 3 ? '/followvisit/outpatientService' : '/followvisit/discharge'
|
this.$router
|
.push({
|
path,
|
query: {
|
month: row.key,
|
serviceType: String(serviceType),
|
fromPendingTask: '1',
|
_t: String(Date.now())
|
}
|
})
|
.catch(() => {})
|
}
|
}
|
}
|
</script>
|
|
<style scoped>
|
.ptb-root {
|
position: fixed;
|
right: 24px;
|
bottom: 24px;
|
z-index: 1998;
|
color: #1f2d3d;
|
font-size: 13px;
|
line-height: 1.4;
|
}
|
|
/* 收起态 */
|
.ptb-pill {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
height: 42px;
|
padding: 0 16px 0 14px;
|
border: none;
|
border-radius: 21px;
|
background: linear-gradient(135deg, #1890ff 0%, #4f8cff 100%);
|
color: #fff;
|
font-size: 13px;
|
cursor: pointer;
|
outline: none;
|
box-shadow: 0 8px 20px rgba(24, 144, 255, 0.35);
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
}
|
|
.ptb-pill:hover {
|
transform: translateY(-2px);
|
box-shadow: 0 12px 24px rgba(24, 144, 255, 0.42);
|
}
|
|
.ptb-pill-dot {
|
width: 8px;
|
height: 8px;
|
border-radius: 50%;
|
background: #fff;
|
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.22);
|
}
|
|
.ptb-pill-num {
|
display: inline-flex;
|
align-items: center;
|
justify-content: center;
|
min-width: 24px;
|
height: 22px;
|
padding: 0 7px;
|
border-radius: 11px;
|
background: rgba(255, 255, 255, 0.94);
|
color: #1890ff;
|
font-weight: 600;
|
}
|
|
/* 展开态 */
|
.ptb-card {
|
width: 364px;
|
max-width: calc(100vw - 24px);
|
background: #fff;
|
border: 1px solid rgba(24, 144, 255, 0.12);
|
border-radius: 14px;
|
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14), 0 2px 6px rgba(15, 23, 42, 0.06);
|
overflow: hidden;
|
animation: ptb-fade-in 0.18s ease-out;
|
}
|
|
@keyframes ptb-fade-in {
|
from {
|
opacity: 0;
|
transform: translateY(8px);
|
}
|
to {
|
opacity: 1;
|
transform: translateY(0);
|
}
|
}
|
|
.ptb-header {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
padding: 11px 12px 11px 14px;
|
background: linear-gradient(135deg, #1890ff 0%, #4f8cff 100%);
|
color: #fff;
|
}
|
|
.ptb-header-left {
|
display: flex;
|
align-items: center;
|
gap: 7px;
|
min-width: 0;
|
}
|
|
.ptb-header-icon {
|
width: 16px;
|
height: 16px;
|
flex: none;
|
}
|
|
.ptb-header-title {
|
font-size: 13px;
|
font-weight: 600;
|
letter-spacing: 0.3px;
|
white-space: nowrap;
|
}
|
|
.ptb-header-actions {
|
display: flex;
|
align-items: center;
|
gap: 2px;
|
}
|
|
.ptb-icon-btn {
|
display: inline-flex;
|
align-items: center;
|
justify-content: center;
|
width: 22px;
|
height: 22px;
|
border-radius: 6px;
|
font-size: 15px;
|
line-height: 1;
|
cursor: pointer;
|
opacity: 0.85;
|
transition: background-color 0.15s ease, opacity 0.15s ease;
|
}
|
|
.ptb-icon-btn:hover {
|
background: rgba(255, 255, 255, 0.2);
|
opacity: 1;
|
}
|
|
.ptb-icon-btn.is-loading {
|
animation: ptb-spin 0.9s linear infinite;
|
}
|
|
@keyframes ptb-spin {
|
to {
|
transform: rotate(360deg);
|
}
|
}
|
|
.ptb-body {
|
padding: 12px 14px 8px;
|
}
|
|
.ptb-user {
|
display: flex;
|
align-items: center;
|
gap: 9px;
|
padding-bottom: 10px;
|
border-bottom: 1px solid #f2f4f8;
|
}
|
|
.ptb-avatar {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
width: 32px;
|
height: 32px;
|
flex: none;
|
border-radius: 50%;
|
background: linear-gradient(135deg, #e6f4ff 0%, #bae0ff 100%);
|
color: #1890ff;
|
font-size: 14px;
|
font-weight: 600;
|
}
|
|
.ptb-user-meta {
|
min-width: 0;
|
flex: 1;
|
}
|
|
.ptb-user-name {
|
overflow: hidden;
|
color: #1f2d3d;
|
font-size: 14px;
|
font-weight: 600;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
.ptb-user-scope {
|
overflow: hidden;
|
color: #8c98a8;
|
font-size: 11px;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
/* 4 行:2026年10月出院随访待处理109个,门诊随访待处理109个 */
|
.ptb-months {
|
margin-top: 4px;
|
}
|
|
.ptb-month-row {
|
display: flex;
|
align-items: center;
|
padding: 6px 6px;
|
margin: 0 -6px;
|
border-radius: 6px;
|
font-size: 12px;
|
line-height: 1.5;
|
white-space: nowrap;
|
transition: background-color 0.15s ease;
|
}
|
|
.ptb-month-row:hover {
|
background: #f5faff;
|
}
|
|
.ptb-month-label {
|
color: #3f4d5f;
|
font-weight: 600;
|
font-variant-numeric: tabular-nums;
|
}
|
|
.ptb-month-seg {
|
color: #5a6b80;
|
}
|
|
/* 数字可点击跳转到随访列表 */
|
.ptb-month-seg.is-clickable {
|
cursor: pointer;
|
transition: color 0.15s ease;
|
}
|
|
.ptb-month-seg.is-clickable:hover {
|
color: #1890ff;
|
}
|
|
.ptb-month-seg.is-clickable:hover .ptb-num {
|
text-decoration: underline;
|
}
|
|
.ptb-month-comma {
|
color: #b6c0cc;
|
}
|
|
.ptb-num {
|
margin: 0 2px;
|
font-size: 13px;
|
font-weight: 700;
|
font-variant-numeric: tabular-nums;
|
}
|
|
.ptb-num.is-discharge {
|
color: #1890ff;
|
}
|
|
.ptb-num.is-outpatient {
|
color: #13a8a8;
|
}
|
|
.ptb-error {
|
margin-top: 8px;
|
padding: 5px 8px;
|
border-radius: 6px;
|
background: #fff7e6;
|
color: #d46b08;
|
font-size: 11px;
|
}
|
|
.ptb-footer {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
padding: 7px 14px;
|
border-top: 1px solid #f2f4f8;
|
background: #fafcff;
|
}
|
|
.ptb-update-time {
|
color: #a3adba;
|
font-size: 11px;
|
}
|
|
.ptb-footer-refresh {
|
color: #1890ff;
|
font-size: 11px;
|
cursor: pointer;
|
}
|
|
.ptb-footer-refresh:hover {
|
text-decoration: underline;
|
}
|
|
@media (max-width: 768px) {
|
.ptb-root {
|
right: 12px;
|
bottom: 12px;
|
}
|
|
.ptb-card {
|
width: calc(100vw - 24px);
|
}
|
|
.ptb-month-row {
|
white-space: normal;
|
flex-wrap: wrap;
|
}
|
}
|
</style>
|