661 lines
16 KiB
Vue
661 lines
16 KiB
Vue
<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>
|