Files
weather-data/weather-data-ui/src/components/alert-marquee/index.vue
T
2026-06-26 17:39:57 +08:00

661 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import {
WarningFilled,
Close,
Bell,
UploadFilled,
CircleCheckFilled,
CircleCloseFilled,
Loading
} from "@element-plus/icons-vue";
import { useAlertMarquee, type AlertMessage } from "@/composables/useAlertMarquee";
import { useImportTaskStore, type ImportTask } from "@/store/importTasks";
import { useFloatingDrag } from "@/composables/useFloatingDrag";
const {
messages,
messagesReversed,
latestMessage,
messageCount,
hasMessages,
drawerVisible,
detailTarget,
detailVisible,
position,
showDetail,
closeDetail,
removeAlert,
clearAll,
startPolling,
stopPolling
} = useAlertMarquee();
const importStore = useImportTaskStore();
// ---- 悬浮滚动条拖拽 ----
const scrollbarRef = ref<HTMLElement | null>(null);
const { isDragging, dragMoved, onPointerDown } = useFloatingDrag(position, scrollbarRef);
// ---- 滚动条手动关闭后,有新通知时自动弹出 ----
const scrollbarDismissed = ref(false);
function dismissScrollbar(): void {
scrollbarDismissed.value = true;
}
function onViewDetail(): void {
if (latestMessage.value) {
showDetail(latestMessage.value);
}
}
// 监听消息数量变化:有新通知时重新显示滚动条
watch(messageCount, (now, prev) => {
if (now > prev) {
scrollbarDismissed.value = false;
}
});
const showScrollbar = computed(() => hasMessages.value && !scrollbarDismissed.value);
// ---- 拖拽时跳过按钮点击 ----
const INTERACTIVE_SEL = "button, a, [role=button]";
function onScrollbarPointerDown(e: PointerEvent): void {
if (e.target instanceof HTMLElement && e.target.closest(INTERACTIVE_SEL)) return;
onPointerDown(e);
}
// ---- 初始位置 ----
const initialized = ref(false);
function initPosition(): void {
if (initialized.value) return;
initialized.value = true;
if (position.value.x === 0 && position.value.y === 0) {
position.value = {
x: (window.innerWidth - 640) / 2,
y: 56
};
}
}
// ---- 窗口大小变化时钳制位置 ----
function clampPosition(): void {
const w = 640;
const h = 43;
position.value = {
x: Math.max(0, Math.min(position.value.x, window.innerWidth - w)),
y: Math.max(52, Math.min(position.value.y, window.innerHeight - h - 20))
};
}
// ---- 窗口 resize 时保持居中 ----
function recenter(): void {
const w = 640;
position.value = {
x: (window.innerWidth - w) / 2,
y: position.value.y
};
}
onMounted(() => {
startPolling(30000);
initPosition();
window.addEventListener("resize", recenter);
});
onUnmounted(() => {
stopPolling();
window.removeEventListener("resize", recenter);
});
// ---- 位置样式 ----
const positionStyle = computed(() => ({
left: position.value.x + "px",
top: position.value.y + "px"
}));
// ---- 统一时间线:警告 + 导入任务,按时间倒序 ----
type TimelineItem =
| { kind: "alert"; data: AlertMessage; ts: string }
| { kind: "import"; data: ImportTask; ts: number };
const timeline = computed<TimelineItem[]>(() => {
const items: TimelineItem[] = [];
for (const msg of messages.value) {
items.push({ kind: "alert", data: msg, ts: msg.publishTime });
}
for (const task of importStore.recentTasks) {
items.push({ kind: "import", data: task, ts: task.createdAt });
}
// 按时间倒序:alert 用 publishTime 字符串不好比,放前面;
// import 用 createdAt 数字好排序
items.sort((a, b) => {
const ta = a.kind === "import" ? (a.data as ImportTask).createdAt : 0;
const tb = b.kind === "import" ? (b.data as ImportTask).createdAt : 0;
// alert 没有精确时间戳,统一用 0 兜底;实际排序以 createdAt 为主
return tb - ta;
});
return items;
});
const hasTimeline = computed(() => timeline.value.length > 0);
// ---- 级别标签类型映射 ----
function tagType(level: string): "info" | "warning" | "danger" {
if (level === "danger") return "danger";
if (level === "warning") return "warning";
return "info";
}
// ---- 导入任务状态辅助 ----
function importStatusText(status: ImportTask["status"]): string {
const map: Record<string, string> = {
pending: "等待中",
uploading: "上传中",
processing: "处理中",
success: "已完成",
error: "失败"
};
return map[status] ?? status;
}
function importProgressPercent(task: ImportTask): number {
if (task.totalRows <= 0) return 0;
return Math.round((task.processedRows / task.totalRows) * 100);
}
function formatImportTime(ts: number): string {
const d = new Date(ts);
return d.toLocaleTimeString("zh-CN", { hour12: false });
}
// ---- 清空全部(仅清空警告,导入任务由各自逻辑管理) ----
function handleClearAll(): void {
clearAll();
importStore.clearCompleted();
}
</script>
<template>
<!-- ===== 悬浮滚动条仅展示最新警告 ===== -->
<div
v-show="showScrollbar"
ref="scrollbarRef"
class="alert-scrollbar"
:class="{ 'is-dragging': isDragging }"
:style="positionStyle"
@pointerdown="onScrollbarPointerDown"
>
<span class="alert-scrollbar__icon">
<el-icon :size="16"><WarningFilled /></el-icon>
</span>
<div class="alert-scrollbar__text-wrap">
<span
class="alert-scrollbar__text"
:class="{ 'is-scroll': latestMessage && latestMessage.content.length > 60 }"
>{{ latestMessage?.content ?? "" }}</span>
</div>
<span class="alert-scrollbar__badge">{{ messageCount }}</span>
<button class="alert-scrollbar__btn-detail" @click.stop="onViewDetail">查看详情</button>
<button class="alert-scrollbar__btn-close" @click.stop="dismissScrollbar">关闭</button>
</div>
<!-- ===== 通知中心抽屉 ===== -->
<el-drawer
v-model="drawerVisible"
direction="rtl"
size="420px"
:with-header="true"
>
<template #header>
<div class="drawer-header">
<span class="drawer-header__title">
<el-icon :size="18"><Bell /></el-icon>
通知中心
</span>
<el-button
v-if="hasTimeline"
text
type="danger"
size="small"
@click="handleClearAll"
>
清空全部
</el-button>
</div>
</template>
<!-- 统一时间线 -->
<div v-if="hasTimeline" class="drawer-list">
<template v-for="item in timeline" :key="item.kind === 'alert' ? item.data.id : item.data.id">
<!-- ===== 警告消息 ===== -->
<div
v-if="item.kind === 'alert'"
class="drawer-item drawer-item--clickable"
@click="showDetail(item.data)"
>
<div class="drawer-item__left">
<span class="drawer-item__dot" :class="'dot--' + item.data.level"></span>
<div class="drawer-item__body">
<p class="drawer-item__content">{{ item.data.content }}</p>
<span class="drawer-item__time">{{ item.data.publishTime }}</span>
</div>
</div>
<button
class="drawer-item__close"
title="移除"
@click.stop="removeAlert(item.data.id)"
>
<el-icon :size="12"><Close /></el-icon>
</button>
</div>
<!-- ===== 导入任务 ===== -->
<div
v-else
class="drawer-item drawer-item--import"
>
<div class="drawer-item__left">
<!-- 状态图标 -->
<span class="drawer-item__import-icon" :class="'import--' + item.data.status">
<el-icon :size="16">
<CircleCheckFilled v-if="item.data.status === 'success'" />
<CircleCloseFilled v-else-if="item.data.status === 'error'" />
<Loading v-else />
</el-icon>
</span>
<div class="drawer-item__body">
<p class="drawer-item__content">
{{ item.data.fileName }}
<span class="import-status-tag" :class="'import-status--' + item.data.status">
{{ importStatusText(item.data.status) }}
</span>
</p>
<!-- 进度条仅处理中显示 -->
<div
v-if="item.data.status === 'processing' && item.data.totalRows > 0"
class="import-progress"
>
<el-progress
:percentage="importProgressPercent(item.data)"
:stroke-width="4"
:show-text="true"
/>
</div>
<!-- 失败消息 -->
<p
v-if="item.data.status === 'error' && item.data.message"
class="import-error-msg"
>{{ item.data.message }}</p>
<span class="drawer-item__time">{{ formatImportTime(item.data.createdAt) }}</span>
</div>
</div>
<!-- 仅已完成/失败的任务可关闭 -->
<button
v-if="item.data.status === 'success' || item.data.status === 'error'"
class="drawer-item__close"
title="移除"
@click.stop="importStore.removeTask(item.data.id)"
>
<el-icon :size="12"><Close /></el-icon>
</button>
</div>
</template>
</div>
<!-- 空状态 -->
<div v-else class="drawer-empty">
<el-icon :size="48" color="#c0c4cc"><Bell /></el-icon>
<p>暂无通知</p>
</div>
</el-drawer>
<!-- ===== 详情弹窗仅警告消息 ===== -->
<el-dialog
v-model="detailVisible"
title="通知详情"
width="520px"
:close-on-click-modal="true"
destroy-on-close
>
<template v-if="detailTarget">
<div class="detail-level">
<el-tag :type="tagType(detailTarget.level)" size="small">
{{ detailTarget.level === "danger" ? "紧急" : detailTarget.level === "warning" ? "警告" : "提示" }}
</el-tag>
</div>
<div class="detail-time">
发布时间{{ detailTarget.publishTime }}
</div>
<div class="detail-content">
{{ detailTarget.content }}
</div>
</template>
<template #footer>
<el-button @click="closeDetail">关闭</el-button>
</template>
</el-dialog>
</template>
<style scoped>
/* ================================================
悬浮滚动条
================================================ */
.alert-scrollbar {
position: fixed;
z-index: 1990;
display: flex;
align-items: center;
gap: 10px;
height: 43px;
width: 640px;
max-width: calc(100vw - 32px);
padding: 0 14px;
background: rgba(245, 108, 108, 0.85);
border-radius: 18px;
box-shadow: 0 2px 10px rgba(245, 108, 108, 0.35);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
cursor: pointer;
user-select: none;
touch-action: none;
transition: opacity 0.2s, box-shadow 0.2s;
}
.alert-scrollbar:hover {
box-shadow: 0 4px 16px rgba(245, 108, 108, 0.5);
}
.alert-scrollbar.is-dragging {
opacity: 0.7;
transition: none !important;
cursor: grabbing;
}
.alert-scrollbar__icon {
flex-shrink: 0;
display: flex;
align-items: center;
color: #fff;
}
.alert-scrollbar__text-wrap {
flex: 1;
overflow: hidden;
white-space: nowrap;
min-width: 0;
}
.alert-scrollbar__text {
display: inline-block;
white-space: nowrap;
font-size: 12px;
color: #fff;
line-height: 43px;
}
.alert-scrollbar__text.is-scroll {
animation: scrollbar-marquee 12s linear infinite;
}
@keyframes scrollbar-marquee {
0% { transform: translateX(0); }
80% { transform: translateX(calc(-100% + 530px)); }
100% { transform: translateX(calc(-100% + 530px)); }
}
.alert-scrollbar__badge {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.3);
color: #fff;
font-size: 11px;
font-weight: 600;
line-height: 1;
}
.alert-scrollbar__btn-detail,
.alert-scrollbar__btn-close {
flex-shrink: 0;
display: flex;
align-items: center;
height: 26px;
padding: 0 10px;
border: none;
border-radius: 13px;
font-size: 12px;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.alert-scrollbar__btn-detail {
background: rgba(255, 255, 255, 0.25);
color: #fff;
margin-left: 4px;
}
.alert-scrollbar__btn-detail:hover {
background: rgba(255, 255, 255, 0.4);
}
.alert-scrollbar__btn-close {
background: rgba(0, 0, 0, 0.15);
color: rgba(255, 255, 255, 0.85);
}
.alert-scrollbar__btn-close:hover {
background: rgba(0, 0, 0, 0.3);
color: #fff;
}
/* ================================================
通知中心抽屉
================================================ */
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.drawer-header__title {
display: flex;
align-items: center;
gap: 6px;
font-size: 16px;
font-weight: 600;
}
.drawer-list {
display: flex;
flex-direction: column;
}
/* 基础条目 */
.drawer-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
padding: 12px 0;
border-bottom: 1px solid #ebeef5;
transition: background 0.15s;
}
.drawer-item--clickable {
cursor: pointer;
}
.drawer-item--clickable:hover {
background: #f5f7fa;
margin: 0 -20px;
padding-left: 20px;
padding-right: 20px;
}
.drawer-item__left {
display: flex;
align-items: flex-start;
gap: 10px;
flex: 1;
min-width: 0;
}
/* 警告色点 */
.drawer-item__dot {
flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: 50%;
margin-top: 6px;
}
.dot--danger { background: #f56c6c; }
.dot--warning { background: #e6a23c; }
.dot--info { background: #409eff; }
/* 导入状态图标 */
.drawer-item__import-icon {
flex-shrink: 0;
display: flex;
align-items: center;
margin-top: 2px;
}
.import--success { color: #67c23a; }
.import--error { color: #f56c6c; }
.import--uploading,
.import--processing,
.import--pending { color: #409eff; }
.drawer-item__body {
flex: 1;
min-width: 0;
}
.drawer-item__content {
margin: 0;
font-size: 13px;
color: #303133;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 导入状态标签 */
.import-status-tag {
display: inline-block;
margin-left: 6px;
font-size: 11px;
vertical-align: middle;
}
.import-status--uploading,
.import-status--processing,
.import-status--pending { color: #409eff; }
.import-status--success { color: #67c23a; }
.import-status--error { color: #f56c6c; }
/* 导入进度条 */
.import-progress {
margin-top: 6px;
}
/* 导入失败消息 */
.import-error-msg {
margin: 4px 0 0;
font-size: 12px;
color: #f56c6c;
line-height: 1.4;
}
.drawer-item__time {
display: block;
margin-top: 4px;
font-size: 11px;
color: #909399;
}
.drawer-item__close {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
margin-top: 2px;
border: none;
border-radius: 4px;
background: transparent;
color: #c0c4cc;
cursor: pointer;
transition: all 0.15s;
}
.drawer-item__close:hover {
background: #f56c6c;
color: #fff;
}
.drawer-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
color: #c0c4cc;
font-size: 14px;
gap: 12px;
}
/* ================================================
详情弹窗
================================================ */
.detail-level {
margin-bottom: 8px;
}
.detail-time {
font-size: 12px;
color: #909399;
margin-bottom: 16px;
}
.detail-content {
font-size: 14px;
color: #303133;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
}
</style>