feat(header): WS 实时推送卡片落地 design/03 .ws-toast——类型色条+图标章+进度条 8s 自动消退+点击进 /msg,替代 Notice.info 临时卡片(审批直批按钮本期不做)
build / build (push) Successful in 1m32s

This commit is contained in:
chenxing
2026-10-09 15:06:23 +08:00
parent 0621ef1ede
commit ac67215c78
2 changed files with 282 additions and 16 deletions
+7 -16
View File
@@ -79,6 +79,9 @@
</ul> </ul>
</header> </header>
<!-- WS 实时推送卡片(design/03 .ws-toast),Teleport 已落到 body -->
<WsToastLayer ref="wsToastRef" />
<Modal v-model="isWallpaperShow" class-name="wallpaper-modal" width="960" :mask="false" title="设置壁纸" <Modal v-model="isWallpaperShow" class-name="wallpaper-modal" width="960" :mask="false" title="设置壁纸"
:transfer="false"> :transfer="false">
<vuescroll> <vuescroll>
@@ -236,7 +239,7 @@ import ContextMenu from "@/components/ContextMenu.vue";
import TeacherCopilotModal from "@/components/TeacherCopilotModal.vue"; import TeacherCopilotModal from "@/components/TeacherCopilotModal.vue";
import BaseApi from "@/api/BaseApi"; import BaseApi from "@/api/BaseApi";
import MsgApprovalModal from "@/components/MsgApprovalModal.vue"; import MsgApprovalModal from "@/components/MsgApprovalModal.vue";
import { Notice } from "view-ui-plus"; import WsToastLayer from "@/components/WsToastLayer.vue";
import { IframePageParmas, RoleInfo } from "@/types"; import { IframePageParmas, RoleInfo } from "@/types";
import { isEqual, orderBy } from "lodash"; import { isEqual, orderBy } from "lodash";
import UserSettingApi from "@/api/UserSettingApi"; import UserSettingApi from "@/api/UserSettingApi";
@@ -249,6 +252,7 @@ import { openAppPage } from "@/utils/bschLinkPage";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const wsToastRef = ref<InstanceType<typeof WsToastLayer>>();
const store = useDefaultStore(); const store = useDefaultStore();
const refMsgApproval = ref<any>(); const refMsgApproval = ref<any>();
const { $conf } = useCurrentInstance(); const { $conf } = useCurrentInstance();
@@ -338,11 +342,6 @@ async function connectMsgWs() {
}; };
} }
/** WS 通知标题用类型标签(appKey 属后端签名标识,帧消费已随 0e57022 口径移除) */
function frameAppName(frame: any) {
return msgTypeLabel(frame ? frame.msgType : null);
}
function onNewMessage(frame: any) { function onNewMessage(frame: any) {
// 他校新消息对当前学校口径无信息量(不计未读、不打扰、也不触发 /msg 页重拉) // 他校新消息对当前学校口径无信息量(不计未读、不打扰、也不触发 /msg 页重拉)
if (!bellVisibleForCurrentSchool(frame)) { if (!bellVisibleForCurrentSchool(frame)) {
@@ -351,11 +350,8 @@ function onNewMessage(frame: any) {
store.incUnread(); store.incUnread();
// /msg 页开着时自刷新(页内监听该事件重拉第一页与摘要) // /msg 页开着时自刷新(页内监听该事件重拉第一页与摘要)
mittBus.emit("msgCenter:newMessage", frame); mittBus.emit("msgCenter:newMessage", frame);
Notice.info({ // design/03 .ws-toast 实时卡片(2026-10-09):类型色条+图标章+进度条自动消退,点击进 /msg
title: `【${frameAppName(frame)}】新消息`, wsToastRef.value?.push(frame);
desc: frame.content || "",
duration: 5,
});
} }
/** /**
@@ -533,11 +529,6 @@ async function changeRole(role: RoleInfo) {
await loadLogoList(); await loadLogoList();
} }
/** 消息类型名(UserMsg msgType:0系统通知/1业务提醒/2审批待办);WS 帧缺 appName 时的降级标签 */
function msgTypeLabel(msgType: number | null) {
return { 0: "系统通知", 1: "业务提醒", 2: "审批待办" }[msgType as 0 | 1 | 2] || "系统通知";
}
async function wallpaperSuccess(response: any, file: any, fileListt: any) { async function wallpaperSuccess(response: any, file: any, fileListt: any) {
await getUserWallpaperList(); await getUserWallpaperList();
} }
+275
View File
@@ -0,0 +1,275 @@
<template>
<Teleport to="body">
<div class="ws-toast-layer">
<div v-for="t in toasts" :key="t.id" class="ws-toast" :class="[t.cls, { out: t.leaving }]"
@click="goMsgCenter(t)">
<div class="wt">
<div class="ic">{{ t.ic }}</div>
<div class="meta">
<div class="nm">{{ t.name }}</div>
<div class="tm">{{ t.tm }}</div>
</div>
<div class="x" @click.stop="dismiss(t)">✕</div>
</div>
<div class="wc"><span class="tt">{{ t.title }}</span>{{ t.content }}</div>
<div class="wf">
<div class="progress"><i :style="{ animationDuration: DURATION_MS + 'ms' }"></i></div>
<div class="go" :class="{ gold: t.cls === 't2' }">{{ t.go }}</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { onUnmounted, reactive } from "vue";
import { useRouter } from "vue-router";
/**
* WS 实时推送卡片(design/03 .ws-toast 落地,2026-10-09):替代 view-ui-plus Notice.info 临时卡片。
* 类型样式:0系统通知 t1(主色)🔔 / 1业务提醒 t2(金色)📋 / 2审批待办 t3(红)⏳;
* 8s 进度条自动消退,点击卡片或「去处理/查看」跳 /msg(列表口径由服务端按校过滤,本卡片只做提醒不承载数据权威)。
* 审批直批按钮(设计稿 approve-row)本期不做,待办仍进消息中心处理。
* 颜色用 var(--x, 回落值):App 目前未定义 --color-* 主题变量,回落取设计稿 :root 同值,未来接主题无需改这里。
*/
const DURATION_MS = 8000;
const LEAVE_MS = 280;
const router = useRouter();
interface ToastItem {
id: number;
cls: string;
ic: string;
name: string;
tm: string;
title: string;
content: string;
go: string;
leaving: boolean;
}
const toasts = reactive<ToastItem[]>([]);
let seq = 0;
const timers = new Map<number, ReturnType<typeof setTimeout>>();
/** 类型 → 卡片样式/图标/脚标文案(msgType 值域与 WsPushPayload 一致:0/1/2,缺省按系统通知) */
function typeSpec(msgType: number | null) {
switch (msgType) {
case 1:
return { cls: "t2", ic: "📋", go: "去处理 →" };
case 2:
return { cls: "t3", ic: "⏳", go: "查看详情 →" };
default:
return { cls: "t1", ic: "🔔", go: "查看全部消息 →" };
}
}
function timeText(frame: any) {
const sendTime: string = frame?.sendTime || "";
const hhmm = sendTime.length >= 16 ? sendTime.slice(11, 16) : "";
return `新消息 · ${hhmm || "刚刚"}`;
}
/** Header onNewMessage 入口:帧已过按校过滤,这里只负责呈现 */
function push(frame: any) {
const spec = typeSpec(frame ? frame.msgType : null);
const item: ToastItem = {
id: ++seq,
cls: spec.cls,
ic: spec.ic,
name: { 0: "系统通知", 1: "业务提醒", 2: "审批待办" }[frame?.msgType as 0 | 1 | 2] || "系统通知",
tm: timeText(frame),
title: frame?.title || "",
content: frame?.content || "",
go: spec.go,
leaving: false,
};
// 最多同屏 4 张:再来的挤掉最老一张,防长消息风暴糊屏
while (toasts.length >= 4) {
drop(toasts[0].id);
}
toasts.push(item);
timers.set(item.id, setTimeout(() => dismiss(item), DURATION_MS));
}
function dismiss(item: ToastItem) {
if (item.leaving) return;
item.leaving = true;
const t = timers.get(item.id);
if (t) clearTimeout(t);
timers.delete(item.id);
setTimeout(() => drop(item.id), LEAVE_MS);
}
function drop(id: number) {
const idx = toasts.findIndex(t => t.id === id);
if (idx >= 0) toasts.splice(idx, 1);
}
function goMsgCenter(item: ToastItem) {
dismiss(item);
router.push("/msg");
}
onUnmounted(() => {
timers.forEach(t => clearTimeout(t));
timers.clear();
});
defineExpose({ push });
</script>
<style scoped lang="scss">
.ws-toast-layer {
position: fixed;
right: 20px;
top: calc(var(--headerH, 70px) + 8px);
z-index: 1500;
display: flex;
flex-direction: column;
gap: 10px;
width: 352px;
pointer-events: none;
}
.ws-toast {
pointer-events: auto;
background: rgba(255, 255, 255, .97);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: var(--radius-m, 10px);
box-shadow: 0 12px 32px rgba(30, 40, 60, .18), 0 2px 6px rgba(30, 40, 60, .08);
border: 1px solid rgba(255, 255, 255, .8);
overflow: hidden;
border-left: 4px solid var(--color-primary, #5979ea);
animation: wsIn .42s cubic-bezier(.2, .9, .3, 1.2);
position: relative;
cursor: pointer;
&.t1 { border-left-color: var(--color-primary, #5979ea); }
&.t2 { border-left-color: var(--color-warning, #e9b954); }
&.t3 { border-left-color: var(--color-error, #e8684a); }
&.out { animation: wsOut .28s ease forwards; }
.wt {
display: flex;
align-items: center;
gap: 10px;
padding: 13px 15px 9px;
.ic {
width: 30px;
height: 30px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
}
.meta {
flex: 1;
min-width: 0;
.nm {
font-size: 14px;
font-weight: 600;
color: #252b33;
}
.tm {
font-size: 11.5px;
color: var(--text-weak, #b3b3b3);
margin-top: 3px;
}
}
.x {
color: #c0c6ce;
font-size: 16px;
cursor: pointer;
line-height: 1;
padding: 2px 6px;
border-radius: 4px;
&:hover {
background: var(--bg-page, #f3f3f3);
color: #7a8088;
}
}
}
&.t1 .ic { background: var(--color-primary-weak, #eef2ff); color: var(--color-primary, #5979ea); }
&.t2 .ic { background: #fdf4e3; color: #c8922a; }
&.t3 .ic { background: #fdecea; color: var(--color-error, #e8684a); }
.wc {
padding: 0 15px 12px 55px;
font-size: 13px;
color: #5a6068;
line-height: 1.65;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
.tt {
color: #2c3138;
font-weight: 500;
}
}
.wf {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 15px;
background: rgba(249, 250, 252, .9);
border-top: 1px solid var(--border-light, #eef0f3);
.progress {
flex: 1;
height: 3px;
background: #e6e9ee;
border-radius: 2px;
overflow: hidden;
i {
display: block;
height: 100%;
background: var(--color-primary, #5979ea);
border-radius: 2px;
animation-name: wsProg;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
}
.go {
font-size: 12px;
color: var(--color-primary, #5979ea);
font-weight: 500;
flex-shrink: 0;
&.gold { color: #c8922a; }
}
}
}
@keyframes wsIn {
from { opacity: 0; transform: translateX(36px) scale(.96); }
to { opacity: 1; transform: none; }
}
@keyframes wsOut {
to { opacity: 0; transform: translateX(36px) scale(.96); margin-bottom: -100px; }
}
@keyframes wsProg {
from { width: 100%; }
to { width: 0; }
}
</style>