- 新增 /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:
+2
-1
@@ -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 => {
|
||||
|
||||
@@ -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 |
@@ -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 {
|
||||
|
||||
@@ -694,7 +694,7 @@ function resetMsgPage() {
|
||||
}
|
||||
function showMsgList() {
|
||||
isMsgShow.value = false;
|
||||
isMsgListShow.value = true;
|
||||
router.push("/msg");
|
||||
refTablePage.value.reloadData();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user