feat(msgcenter,settings): 消息中心与设置中心路由化 + 背景层双卡改造
build / build (push) Successful in 13m13s

- 新增 /msg 消息中心路由页:左右双卡(左卡按分类/按应用双模式、右卡工具栏+列表),
  mock 数据,含时间格式/未读红点/已读三重置灰/审批直批按钮
- 新增 /settings 设置中心路由页:左导航卡+右内容卡,10 个 pane,?pane= 双向同步,
  管理员/普通用户角色差异(普通用户直达管理项渲染无权限黄条)
- 侧栏新增「设置」一级入口(前台硬编码补位,后台已配同 url 时自动去重)+ 分隔线 + 本地齿轮图标
- SysSetting.vue 卡片化:圆角 5→10px + 阴影,统一为 0 10px 20px rgba(0,0,0,.1)
- .base-mask 提升:rgba(255,255,255,.35)→.55,blur(2px)→10px
- Header 铃铛「查看全部」改跳 /msg;App.vue 菜单守卫放行 /msg、/settings(前台路由不在后端菜单表)
This commit is contained in:
chenxing
2026-09-30 09:28:50 +08:00
parent 08fa15db24
commit aa3dafa932
9 changed files with 1737 additions and 12 deletions
+2 -1
View File
@@ -113,7 +113,8 @@ async function loadUserMenuBarList() {
} else {
findIndex = menuList.findIndex(x => x.url === route.path);
}
if (findIndex === -1) router.push({ name: "index" });
// /msg、/settings 是前台硬编码路由(不在后台菜单表里),直达/刷新不应被弹回桌面
if (findIndex === -1 && !["/msg", "/settings"].includes(route.path)) router.push({ name: "index" });
}
const getRouterKey: (route: any) => string = route => {
+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>

After

Width:  |  Height:  |  Size: 826 B

+3 -3
View File
@@ -104,9 +104,9 @@
background-attachment: fixed;
}
.base-mask {
background: rgba(255, 255, 255, 0.35);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.app-loading {
+1 -1
View File
@@ -694,7 +694,7 @@ function resetMsgPage() {
}
function showMsgList() {
isMsgShow.value = false;
isMsgListShow.value = true;
router.push("/msg");
refTablePage.value.reloadData();
}
+31 -5
View File
@@ -5,17 +5,18 @@
<SvgIcon name="menu-operate" color="#ececec" />
</li>
<template v-for="(item, index) in menuList" :key="index">
<!-- 硬编码补位的「设置」一级入口上方的分隔线(业务入口 vs 系统入口分层) -->
<li v-if="item.sep" class="menu-sep" aria-hidden="true"></li>
<li
v-wave="myWave"
@click="linkPage(item, index)"
:class="{ active: index == activeIndex, 'first-bottom': index == menuFirstBottomIndex }"
>
<a :title="item.name">
<FetchSvg
size="24"
:src="$conf.serviceHost + item.icon"
:color="index == activeIndex ? '#fff' : '#ececec'"
/>
<SvgIcon v-if="item.localIcon" :name="item.localIcon" size="24"
:color="index == activeIndex ? '#fff' : '#ececec'" />
<FetchSvg v-else size="24" :src="$conf.serviceHost + item.icon"
:color="index == activeIndex ? '#fff' : '#ececec'" />
<span>{{ item.name }}</span>
</a>
</li>
@@ -65,9 +66,26 @@ const route = useRoute(),
const store = useDefaultStore();
const { menuVisible } = storeToRefs(store);
/** 硬编码的「设置」一级入口:isNative 直走 router.push,图标用本地 svg sprite */
const SETTINGS_MENU_ITEM = {
id: -1,
name: "设置",
url: "/settings",
isNative: true,
isBottom: true,
orderNum: 9999,
localIcon: "menu-settings",
sep: true,
};
const menuList = computed<any[]>(() => {
let topArr = store.curUserMenuList.filter(x => x.isBottom !== true).sort((a, b) => a.orderNum - b.orderNum);
let bottomArr = store.curUserMenuList.filter(x => x.isBottom === true).sort((a, b) => a.orderNum - b.orderNum);
// 「设置」一级入口走前台硬编码补位(路径 A,不动后端菜单表);
// 若后台菜单表已配置同 url 的项则不重复追加,避免双入口
if (!store.curUserMenuList.some(x => x.url === "/settings")) {
bottomArr = [...bottomArr, SETTINGS_MENU_ITEM];
}
return [...topArr, ...bottomArr];
});
@@ -198,6 +216,14 @@ async function linkPage(item: any, index: number) {
bottom: 8px;
left: 5px;
}
li.menu-sep {
width: 36px;
height: 1px;
margin: 6px auto;
padding: 0;
background: rgba(255, 255, 255, 0.22);
pointer-events: none;
}
li:hover {
background-color: rgba(255, 255, 255, 0.15);
}
+12
View File
@@ -71,5 +71,17 @@ const routes: RouteRecordRaw[] = [
component: SysTempManage,
meta: { title: "模板管理" },
},
{
path: "/msg",
name: "Msg",
component: () => import("@/views/msg/index.vue"),
meta: { title: "消息" },
},
{
path: "/settings",
name: "Settings",
component: () => import("@/views/Settings.vue"),
meta: { title: "设置" },
},
];
export default routes;
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -482,7 +482,8 @@ onUnmounted(() => {
height: 100%;
background: #fff;
padding: 15px;
border-radius: 5px;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
overflow-y: auto;
.tree-title {
@@ -494,7 +495,8 @@ onUnmounted(() => {
.right-box {
height: 100%;
background: #fff;
border-radius: 5px;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
padding: 15px;
overflow: hidden;
display: flex;
+673
View File
@@ -0,0 +1,673 @@
<template>
<!-- 左右双卡(B 方案):壳透明不平铺白,白底在两张卡上,壁纸透出。
gap 必须加在两卡的直接父容器 .msg-shell 上(不要中间再套一层)。 -->
<div class="msg-shell">
<!-- 左卡:按分类 / 按应用 -->
<aside class="msg-side">
<div class="mode-switch">
<button :class="{ on: mode === 'cat' }" @click="switchMode('cat')">按分类</button>
<button :class="{ on: mode === 'app' }" @click="switchMode('app')">按应用</button>
</div>
<!-- 模式一:按分类 -->
<div v-show="mode === 'cat'">
<div class="side-title">消息分类</div>
<div
v-for="c in catList"
:key="c.key"
:class="['cat', { active: curCat === c.key }]"
@click="onClickCat(c.key)"
>
<span class="dot" :style="{ background: c.color }"></span>{{ c.name }}
<span class="cnt">{{ c.cnt }}</span>
</div>
</div>
<!-- 模式二:按应用(平铺全部应用,首项「全部」+ 分隔线) -->
<div v-show="mode === 'app'">
<div class="side-title">全部应用</div>
<div :class="['cat', { active: curApp === 'all' }]" @click="onClickApp('all')">
<span class="dot dot-primary"></span>全部<span class="cnt">{{ msgs.length }}</span>
</div>
<div class="side-sep"></div>
<div
v-for="a in appList"
:key="a.name"
:class="['cat', { active: curApp === a.name }]"
@click="onClickApp(a.name)"
>
<span class="dot dot-primary"></span>{{ a.name }}<span class="cnt">{{ a.cnt }}</span>
</div>
</div>
</aside>
<!-- 右卡:工具栏 + 消息列表 -->
<div class="msg-main">
<div class="msg-toolbar">
<div class="tb-left">
<h3>{{ curTitle }}</h3>
<span class="sub"><i class="live"></i>未读 {{ unreadTotal }} 条</span>
</div>
<div class="tb-right">
<input v-model="searchText" class="msg-search" placeholder="搜索消息内容…" />
<button class="btn plain sm" @click="readAll">全部已读</button>
<button class="btn ghost sm" @click="refresh">刷新</button>
<div class="skin-switch">
<span class="lb">消息样式</span>
<div class="seg">
<button :class="{ on: skin === 'stripe' }" @click="skin = 'stripe'">彩色条带</button>
<button :class="{ on: skin === 'badge' }" @click="skin = 'badge'">徽标</button>
</div>
</div>
</div>
</div>
<div class="msg-body">
<div class="msg-list" :data-skin="skin">
<div v-if="renderList.length === 0" class="empty">
<div class="ic">✅</div>
<div>暂无消息</div>
</div>
<div
v-for="(m, i) in renderList"
:key="i"
:class="['msg-card', m.unread ? ['unread', m.t === 2 ? 'm2' : m.t === 3 ? 'm3' : ''] : '']"
>
<div class="row1">
<div :class="['type-ic', m.t === 2 ? 'biz' : m.t === 3 ? 'appr' : 'sys']" :data-label="typeLabel(m.t)">
{{ typeIcon(m.t) }}
</div>
<span class="app">{{ m.app }}</span>
<span class="type-tag">{{ typeName(m.t) }}</span>
<span class="unread-scope"><span class="unread-dot"></span></span>
</div>
<div class="row2"><span class="tt">{{ m.tt }}</span>{{ m.body }}</div>
<div class="row3">
<span class="time">{{ fmtTime(m.min) }}</span>
<div class="act">
<template v-if="m.unread && m.approve">
<button class="btn sm btn-ok" @click="onClickMsg(m)">同意</button>
<button class="btn plain sm btn-no" @click="onClickMsg(m)">拒绝</button>
</template>
<button class="btn ghost sm" @click="onClickMsg(m)">{{ m.t === 2 ? "去处理" : "查看详情" }}</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from "vue";
/**
* 消息中心路由页(/msg)。
* 本轮数据全部为 mock(照 design/03 原型 APP_MSGS),接口对接(readMsgCenter / msgList /
* handleMsg / WS)属独立一轮,见方案文档 §6。
*/
interface MockMsg {
app: string;
t: number; // 1 系统通知 / 2 业务提醒 / 3 审批待办
tt: string;
body: string;
min: number; // 距现在的分钟数(mock 时间)
unread: boolean;
approve?: boolean;
}
/** mock 消息池(与原型 APP_MSGS 一致),刷新时从它重新克隆 */
const MOCK_MSGS: MockMsg[] = [
{ app: "请假", t: 3, tt: "张老师的调课申请待你审批:", body: "因公出差,申请将周三 3-4 节《数据结构》调整至周五 5-6 节。", min: 3, unread: true, approve: true },
{ app: "通知公告", t: 1, tt: "系统维护通知:", body: "平台将于今晚 23:00-24:00 进行版本升级,期间可能出现短暂无法访问。", min: 25, unread: true },
{ app: "请假", t: 3, tt: "李老师的病假申请待审批:", body: "申请 9 月 30 日病假一天,请予审批。", min: 40, unread: true, approve: true },
{ app: "公文申请", t: 2, tt: "公文待办:", body: "《关于秋季运动会的通知》待你签发。", min: 60, unread: true },
{ app: "通知公告", t: 1, tt: "放假安排:", body: "国庆假期 10 月 1 日至 7 日,请各部门提前做好工作交接。", min: 120, unread: true },
{ app: "教务管理", t: 2, tt: "成绩录入提醒:", body: "高二(3)班期中考试成绩尚未录入完毕,请于本周五前完成。", min: 180, unread: true },
{ app: "公文申请", t: 2, tt: "公文待阅:", body: "《关于加强校园安全管理的通知》请查阅。", min: 240, unread: true },
{ app: "用户首页", t: 1, tt: "门户栏目更新:", body: "「校园动态」栏目已上线,可在首页订阅。", min: 300, unread: true },
{ app: "报修", t: 2, tt: "报修派单:", body: "教学楼 301 多媒体设备故障,已派单至你处。", min: 360, unread: true },
{ app: "流程中心", t: 1, tt: "流程模板变更:", body: "「采购申请」流程新增一级审批,请留意。", min: 420, unread: true },
{ app: "学校管理", t: 1, tt: "组织架构调整:", body: "教务处新增「教研组」二级部门。", min: 480, unread: true },
{ app: "教师专业发展", t: 2, tt: "培训报名提醒:", body: "市级骨干教师培训报名明日截止。", min: 600, unread: true },
{ app: "场地预约", t: 2, tt: "场地冲突提醒:", body: "你预约的报告厅与校务会时间冲突,请调整。", min: 720, unread: true },
{ app: "请假", t: 3, tt: "已办结:", body: "王老师的年假申请已通过。", min: 1440, unread: false },
{ app: "通知公告", t: 1, tt: "新功能上线:", body: "应用中心已支持按角色分组管理,可在「设置 → 应用管理」中配置。", min: 1500, unread: false },
];
const msgs = ref<MockMsg[]>(MOCK_MSGS.map(m => ({ ...m })));
/** 左卡模式:cat 按分类 / app 按应用 */
const mode = ref<"cat" | "app">("cat");
const curCat = ref<string>("all");
const curApp = ref<string>("all");
const searchText = ref("");
/** 消息样式:stripe 彩色条带 / badge 徽标 */
const skin = ref<"stripe" | "badge">("stripe");
const unreadTotal = computed(() => msgs.value.filter(m => m.unread).length);
/** 分类导航(计数实时随已读状态变化) */
const catList = computed(() => [
{ key: "all", name: "全部消息", color: "#5979ea", cnt: msgs.value.length },
{ key: "sys", name: "系统通知", color: "#5979ea", cnt: msgs.value.filter(m => m.t === 1).length },
{ key: "biz", name: "业务提醒", color: "#e9b954", cnt: msgs.value.filter(m => m.t === 2).length },
{ key: "appr", name: "审批待办", color: "#e8684a", cnt: msgs.value.filter(m => m.t === 3).length },
{ key: "read", name: "已读", color: "#c0c6ce", cnt: msgs.value.filter(m => !m.unread).length },
]);
/** 应用导航:按时间倒序首次出现的顺序平铺 */
const appList = computed(() => {
const sorted = [...msgs.value].sort((a, b) => a.min - b.min);
const names: string[] = [];
sorted.forEach(m => {
if (!names.includes(m.app)) names.push(m.app);
});
return names.map(name => ({ name, cnt: msgs.value.filter(m => m.app === name).length }));
});
/** 右卡列表:模式内过滤 + 搜索,统一按时间倒序(已读消息保留,供回查) */
const renderList = computed(() => {
let rows = [...msgs.value];
if (mode.value === "cat") {
if (curCat.value === "read") rows = rows.filter(m => !m.unread);
else if (curCat.value !== "all") rows = rows.filter(m => m.t === Number(curCat.value === "sys" ? 1 : curCat.value === "biz" ? 2 : 3));
} else {
if (curApp.value !== "all") rows = rows.filter(m => m.app === curApp.value);
}
const kw = searchText.value.trim();
if (kw) rows = rows.filter(m => (m.app + m.tt + m.body).includes(kw));
return rows.sort((a, b) => a.min - b.min);
});
const curTitle = computed(() => {
if (mode.value === "cat") {
const hit = catList.value.find(c => c.key === curCat.value);
return hit ? hit.name : "全部消息";
}
return curApp.value === "all" ? "全部应用消息" : curApp.value;
});
function switchMode(m: "cat" | "app") {
mode.value = m;
}
function onClickCat(key: string) {
curCat.value = key;
}
function onClickApp(key: string) {
curApp.value = key;
}
/** mock:点击「同意/拒绝/查看详情」视为已读(真实 readMsgCenter 对接属下一轮) */
function onClickMsg(m: MockMsg) {
m.unread = false;
}
/** mock:全部已读 */
function readAll() {
msgs.value.forEach(m => (m.unread = false));
}
/** mock:刷新 = 恢复初始数据 */
function refresh() {
msgs.value = MOCK_MSGS.map(m => ({ ...m }));
}
function typeName(t: number) {
return t === 3 ? "审批待办" : t === 2 ? "业务提醒" : "系统通知";
}
function typeLabel(t: number) {
return t === 3 ? "审批" : t === 2 ? "提醒" : "系统";
}
function typeIcon(t: number) {
return t === 3 ? "⏳" : t === 2 ? "📋" : "🔔";
}
/** 时间格式:刚刚 / N 分钟前 / N 小时前 / 昨天 HH:mm / MM-DD */
function fmtTime(min: number) {
if (min < 1) return "刚刚";
if (min < 60) return `${min} 分钟前`;
if (min < 1440) return `${Math.floor(min / 60)} 小时前`;
if (min < 2880) {
const hm = new Date(Date.now() - min * 60000);
const pad = (n: number) => String(n).padStart(2, "0");
return `昨天 ${pad(hm.getHours())}:${pad(hm.getMinutes())}`;
}
const d = new Date(Date.now() - min * 60000);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
</script>
<style scoped lang="scss">
/* 壳:透明,不平铺白,壁纸透出(B 方案) */
.msg-shell {
display: flex;
gap: 16px; /* ★ 两卡间隔 —— 必须加在两卡的直接父容器(本容器)上 */
height: calc(100% - var(--headerH));
padding: 0 20px 20px;
background: transparent;
border: none;
box-shadow: none;
}
/* 左卡:分类 / 应用导航 */
.msg-side {
width: 224px;
flex-shrink: 0;
background: #fff;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
padding: 16px 12px;
overflow-y: auto;
}
/* 右卡:工具栏 + 列表 */
.msg-main {
flex: 1;
min-width: 0;
background: #fff;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
overflow: hidden;
display: flex;
flex-direction: column;
}
.mode-switch {
display: flex;
background: #f3f3f3;
padding: 3px;
border-radius: 8px;
gap: 2px;
margin-bottom: 14px;
button {
flex: 1;
padding: 6px 0;
border-radius: 6px;
font-size: 12.5px;
color: #5a6068;
transition: all 0.18s;
white-space: nowrap;
border: none;
background: none;
cursor: pointer;
&.on {
background: #fff;
color: #5979ea;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
}
}
.side-title {
font-size: 12px;
color: #999;
font-weight: 600;
padding: 4px 12px 10px;
}
.cat {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 12px;
border-radius: 6px;
font-size: 13.5px;
color: #4a4f55;
cursor: pointer;
margin-bottom: 2px;
transition: all 0.18s;
&:hover {
background: rgba(89, 121, 234, 0.08);
color: #5979ea;
}
&.active {
background: #5979ea;
color: #fff;
font-weight: 500;
}
.cnt {
margin-left: auto;
font-size: 12px;
color: #999;
}
&.active .cnt {
color: rgba(255, 255, 255, 0.85);
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-primary {
background: #5979ea;
}
}
.side-sep {
height: 1px;
background: #eef0f3;
margin: 12px;
}
.msg-toolbar {
min-height: 58px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 22px;
border-bottom: 1px solid #eef0f3;
gap: 14px;
flex-wrap: wrap;
.tb-left {
display: flex;
align-items: center;
gap: 12px;
h3 {
font-size: 16px;
font-weight: 600;
color: #2c3138;
}
.sub {
font-size: 12px;
color: #999;
display: flex;
align-items: center;
gap: 5px;
}
.live {
width: 6px;
height: 6px;
border-radius: 50%;
background: #45b972;
box-shadow: 0 0 0 3px rgba(69, 185, 114, 0.2);
}
}
.tb-right {
display: flex;
align-items: center;
gap: 8px;
}
}
.msg-search {
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 16px;
padding: 0 14px;
font-size: 13px;
width: 190px;
outline: none;
&:focus {
border-color: #5979ea;
box-shadow: 0 0 0 3px rgba(89, 121, 234, 0.12);
}
}
.msg-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 22px 26px;
}
.msg-card {
position: relative;
background: #fff;
border: 1px solid #eef0f3;
border-radius: 10px;
padding: 15px 18px 15px 20px;
margin-bottom: 11px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
transition: all 0.2s;
overflow: hidden;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #dfe3ea;
transform: translateY(-1px);
}
/* 卡片左侧色带(stripe 模式) */
&::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: #dfe3ea;
}
&.unread::before {
background: #5979ea;
}
&.unread.m2::before {
background: #e9b954;
}
&.unread.m3::before {
background: #e8684a;
}
.row1 {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.type-ic {
width: 30px;
height: 30px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
&.sys {
background: #eef2ff;
color: #5979ea;
}
&.biz {
background: #fdf4e3;
color: #c8922a;
}
&.appr {
background: #fdecea;
color: #e8684a;
}
}
.app {
font-size: 14px;
font-weight: 600;
color: #2c3138;
}
.type-tag {
font-size: 11px;
padding: 1px 8px;
border-radius: 9px;
background: #f3f3f3;
color: #7a8088;
}
.row2 {
font-size: 13.5px;
color: #5a6068;
line-height: 1.65;
padding-left: 40px;
.tt {
color: #2c3138;
font-weight: 500;
}
}
.row3 {
display: flex;
align-items: center;
gap: 10px;
margin-top: 11px;
padding-left: 40px;
min-height: 32px;
}
.time {
font-size: 12px;
color: #b3b3b3;
flex-shrink: 0;
}
.act {
margin-left: auto;
display: flex;
gap: 8px;
}
}
/* 未读小红点(唯一未读标记,8px #f24822 独立状态色,与类型色脱钩) */
.unread-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f24822;
flex-shrink: 0;
box-shadow: 0 0 0 3px rgba(242, 72, 34, 0.14);
}
.unread-scope {
display: inline-flex;
align-items: center;
flex-shrink: 0;
margin-left: auto;
}
/* 已读卡片:整卡置灰 + 色带转灰 + 红点消失,hover 回补到 .82 保可读 */
.msg-card:not(.unread)::before {
background: #e4e8ee;
}
.msg-card:not(.unread) {
opacity: 0.5;
background: #fafbfc;
&:hover {
opacity: 0.82;
}
}
.msg-card:not(.unread) .unread-dot {
display: none;
}
/* 徽标模式:类型化为「图标+文字」圆角块,去掉左侧竖条 */
.msg-list[data-skin="badge"] .msg-card::before {
display: none;
}
.msg-list[data-skin="badge"] .type-ic {
width: auto;
height: 26px;
padding: 0 11px;
border-radius: 7px;
font-size: 12.5px;
font-weight: 500;
gap: 5px;
}
.msg-list[data-skin="badge"] .type-ic::after {
content: attr(data-label);
font-size: 12.5px;
font-weight: 500;
}
.msg-list[data-skin="badge"] .row2,
.msg-list[data-skin="badge"] .row3 {
padding-left: 0;
}
/* 工具栏内样式切换器 */
.skin-switch {
display: flex;
align-items: center;
gap: 8px;
padding-left: 12px;
border-left: 1px solid #eef0f3;
.lb {
font-size: 12px;
color: #999;
white-space: nowrap;
}
}
.seg {
display: flex;
background: #f3f3f3;
padding: 3px;
border-radius: 8px;
gap: 2px;
button {
padding: 4px 11px;
border-radius: 6px;
font-size: 12px;
color: #5a6068;
transition: all 0.18s;
white-space: nowrap;
border: none;
background: none;
cursor: pointer;
&.on {
background: #fff;
color: #5979ea;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
}
}
/* 按钮(卡内动作) */
.btn {
height: 34px;
padding: 0 18px;
border-radius: 6px;
font-size: 13px;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
justify-content: center;
border: none;
cursor: pointer;
&.primary {
background: #5979ea;
color: #fff;
}
&.ghost {
background: #fff;
color: #5979ea;
border: 1px solid #5979ea;
&:hover {
background: #eef2ff;
}
}
&.plain {
background: #fff;
color: #4a4f55;
border: 1px solid #dcdfe6;
&:hover {
border-color: #5979ea;
color: #5979ea;
}
}
&.sm {
height: 28px;
padding: 0 12px;
font-size: 12px;
}
&.btn-ok {
background: #45b972;
color: #fff;
}
&.btn-no {
color: #e8684a;
border-color: #e8684a;
}
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
color: #b3b3b3;
gap: 12px;
.ic {
font-size: 44px;
opacity: 0.5;
}
}
</style>