feat(header): WS 实时推送卡片落地 design/03 .ws-toast——类型色条+图标章+进度条 8s 自动消退+点击进 /msg,替代 Notice.info 临时卡片(审批直批按钮本期不做)
build / build (push) Successful in 1m32s
build / build (push) Successful in 1m32s
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user