build / build (push) Successful in 43s
- 按分类计数改分类总数(sysTotal/bizTotal/approvalTotal,与列表内容对齐),已读=已读总数、全部=全部总数 - 按应用角标改 countByApp 按应用消息总数(含已读,已读后不清零),总数>0 才显示、总数降序 - 应用匹配/筛选全面改 appId:行与计数出参带 appId、筛选传 appId,appKey 不再出现在消息中心前端
867 lines
26 KiB
Vue
867 lines
26 KiB
Vue
<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>
|
||
|
||
<!-- 模式二:按应用(应用清单=GetUserApp 用户应用全集;角标=countByApp 消息总数,0 不显示) -->
|
||
<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">{{ summaryTotalHint }}</span>
|
||
</div>
|
||
<div class="side-sep"></div>
|
||
<div
|
||
v-for="a in appList"
|
||
:key="a.appId"
|
||
:class="['cat', { active: curApp === String(a.appId) }]"
|
||
@click="onClickApp(String(a.appId))"
|
||
>
|
||
<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>未读 {{ unreadCount }} 条</span>
|
||
</div>
|
||
<div class="tb-right">
|
||
<input v-model="searchText" class="msg-search" placeholder="搜索消息内容…" @input="onSearchInput" />
|
||
<button class="btn plain sm" @click="readAll">全部已读</button>
|
||
<button class="btn ghost sm" @click="reload">刷新</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" ref="refMsgBody" @scroll="onScroll">
|
||
<div class="msg-list" :data-skin="skin">
|
||
<div v-if="loadError && rows.length === 0" class="empty">
|
||
<div class="ic">⚠️</div>
|
||
<div>消息加载失败</div>
|
||
<button class="btn ghost sm" @click="reload">重试</button>
|
||
</div>
|
||
<div v-else-if="rows.length === 0 && !loading" class="empty">
|
||
<div class="ic">✅</div>
|
||
<div>暂无消息</div>
|
||
</div>
|
||
<div
|
||
v-for="m in rows"
|
||
:key="m.msgId"
|
||
:class="['msg-card', isUnread(m) ? ['unread', m.msgType === 1 ? 'mbiz' : m.msgType === 2 ? 'mappr' : ''] : '']"
|
||
@click="onClickCard(m)"
|
||
>
|
||
<div class="row1">
|
||
<div :class="['type-ic', m.msgType === 1 ? 'biz' : m.msgType === 2 ? 'appr' : 'sys']" :data-label="typeLabel(m.msgType)">
|
||
{{ typeIcon(m.msgType) }}
|
||
</div>
|
||
<span class="app">{{ m.appName || typeAppName(m) }}</span>
|
||
<span class="type-tag">{{ typeName(m.msgType) }}</span>
|
||
<span class="unread-scope"><span class="unread-dot"></span></span>
|
||
</div>
|
||
<div class="row2"><span class="tt">{{ m.title }}</span>{{ m.content }}</div>
|
||
<div class="row3">
|
||
<span class="time">{{ fmtTime(m.sendTime) }}</span>
|
||
<div class="act">
|
||
<template v-if="isUnread(m) && m.msgType === 2 && m.msgId">
|
||
<button class="btn sm btn-ok" @click.stop="openApproval(m, 'approve')">同意</button>
|
||
<button class="btn plain sm btn-no" @click.stop="openApproval(m, 'reject')">拒绝</button>
|
||
</template>
|
||
<button v-if="m.showJump && m.jumpUrl" class="btn ghost sm" @click.stop="onJump(m)">去处理</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="loading" class="loading-more">加载中…</div>
|
||
<div v-else-if="rows.length > 0 && !hasMore" class="loading-more">没有更多了</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 审批直批弹窗(与 Header 共享组件;成功后卡片置已读并刷新) -->
|
||
<MsgApprovalModal ref="refApproval" @handled="onApprovalHandled" />
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||
import { storeToRefs } from "pinia";
|
||
import BaseApi from "@/api/BaseApi";
|
||
import MsgApprovalModal from "@/components/MsgApprovalModal.vue";
|
||
import mittBus from "@/utils/mitt";
|
||
import { useDefaultStore } from "@/store";
|
||
|
||
/**
|
||
* 消息中心路由页(/msg)。数据层对接 msgCenter 真实接口(消息对接 2026-09-30):
|
||
* 列表服务端筛选 + 滚动分页、左栏计数走 summary、已读/全部已读/直批/去处理接真实动作。
|
||
* 字段映射:msgType 0系统/1业务/2审批;isRead 1=已读;appName/jumpUrl/showJump 由后端聚合改写。
|
||
*/
|
||
|
||
interface MsgRow {
|
||
msgId: number;
|
||
title: string;
|
||
content: string;
|
||
msgType: number; // 0 系统通知 / 1 业务提醒 / 2 审批待办
|
||
appName?: string | null;
|
||
appId?: number | null; // 注册表应用 id(appKey 不下发前端)
|
||
sendTime?: string; // yyyy-MM-dd HH:mm:ss
|
||
isRead: number; // 1=已读
|
||
jumpUrl?: string | null;
|
||
showJump?: boolean;
|
||
}
|
||
|
||
const store = useDefaultStore();
|
||
/** 未读数与 Header 铃铛 / LeftMenu 角标同源(store) */
|
||
const { unreadCount } = storeToRefs(store);
|
||
|
||
/** 左卡模式: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 rows = ref<MsgRow[]>([]);
|
||
const total = ref(0);
|
||
const page = ref(1);
|
||
const PAGE_SIZE = 20;
|
||
const loading = ref(false);
|
||
const loadError = ref(false);
|
||
const hasMore = computed(() => rows.value.length < total.value);
|
||
const refMsgBody = ref<HTMLElement | null>(null);
|
||
|
||
/** summary 左栏计数(分类总数口径;旧版 UserMsg 无字段时缺省 → 显示 —) */
|
||
const catCounts = ref<{ sysTotal?: number; bizTotal?: number; approvalTotal?: number; total?: number; readCount?: number }>({});
|
||
const summaryTotalHint = computed(() => (catCounts.value.total == null ? "—" : String(catCounts.value.total)));
|
||
|
||
function isUnread(m: MsgRow) {
|
||
return m.isRead !== 1;
|
||
}
|
||
|
||
/** appName 缺失降级:按 appId 匹配用户应用清单(与桌面网格同源),再退类型标签 */
|
||
function typeAppName(m: MsgRow) {
|
||
if (m.appId != null) {
|
||
const hit = userApps.value.find(a => a.appId === m.appId);
|
||
if (hit && hit.name) return hit.name;
|
||
}
|
||
return typeName(m.msgType);
|
||
}
|
||
|
||
/** 分类导航(计数=分类消息总数含已读,与列表内容对齐;「全部」=全部总数、「已读」=已读总数) */
|
||
const catList = computed(() => [
|
||
{ key: "all", name: "全部消息", color: "#5979ea", cnt: catCounts.value.total == null ? "—" : catCounts.value.total },
|
||
{ key: "sys", name: "系统通知", color: "#5979ea", cnt: catCounts.value.sysTotal == null ? "—" : catCounts.value.sysTotal },
|
||
{ key: "biz", name: "业务提醒", color: "#e9b954", cnt: catCounts.value.bizTotal == null ? "—" : catCounts.value.bizTotal },
|
||
{ key: "appr", name: "审批待办", color: "#e8684a", cnt: catCounts.value.approvalTotal == null ? "—" : catCounts.value.approvalTotal },
|
||
{ key: "read", name: "已读", color: "#c0c6ce", cnt: catCounts.value.readCount == null ? "—" : catCounts.value.readCount },
|
||
]);
|
||
|
||
/** 应用导航:清单=用户应用全集(GetUserApp);角标=countByApp 按应用消息总数(含已读),0 不显示 */
|
||
const userApps = ref<{ appId: number; name: string }[]>([]);
|
||
const appCountByAppId = ref<Record<number, { total: number; unread: number }>>({});
|
||
const appList = computed(() =>
|
||
userApps.value
|
||
.map(a => ({ appId: a.appId, name: a.name, cnt: appCountByAppId.value[a.appId]?.total || 0 }))
|
||
.filter(a => a.cnt > 0)
|
||
.sort((x, y) => y.cnt - x.cnt) // 总数多的排前(稳定排序,同计数保持注册表顺序)
|
||
);
|
||
|
||
/** 应用清单一次拉齐(GetUserApp;失败降级空,不打扰列表) */
|
||
async function loadApps() {
|
||
try {
|
||
const groups: any[] = await BaseApi.getUserApp(store.getCurUserId, store.getCurSchId);
|
||
userApps.value = (groups || [])
|
||
.flatMap((g: any) => (g && Array.isArray(g.apps) ? g.apps : []))
|
||
.filter((a: any) => a && a.id != null)
|
||
.map((a: any) => ({ appId: Number(a.id), name: a.name }));
|
||
} catch (e) {
|
||
/* 应用清单失败降级空 */
|
||
}
|
||
}
|
||
|
||
/** 当前视图的列表标题 */
|
||
const curTitle = computed(() => {
|
||
if (mode.value === "cat") {
|
||
const hit = catList.value.find(c => c.key === curCat.value);
|
||
return hit ? hit.name : "全部消息";
|
||
}
|
||
if (curApp.value === "all") return "全部应用消息";
|
||
const hit = appList.value.find(a => String(a.appId) === curApp.value);
|
||
return hit ? hit.name : "全部应用消息";
|
||
});
|
||
|
||
/** 当前筛选条件 → 服务端参数(筛选语义归服务端,本地不过滤;应用筛选传注册表 appId) */
|
||
function currentParams(extra: { page: number }) {
|
||
const params: { page: number; count: number; msgType?: number; appId?: string; isRead?: number; keyword?: string } = {
|
||
page: extra.page,
|
||
count: PAGE_SIZE,
|
||
};
|
||
if (mode.value === "cat") {
|
||
if (curCat.value === "sys") params.msgType = 0;
|
||
else if (curCat.value === "biz") params.msgType = 1;
|
||
else if (curCat.value === "appr") params.msgType = 2;
|
||
else if (curCat.value === "read") params.isRead = 1;
|
||
} else if (curApp.value !== "all") {
|
||
params.appId = curApp.value;
|
||
}
|
||
const kw = searchText.value.trim();
|
||
if (kw) params.keyword = kw;
|
||
return params;
|
||
}
|
||
|
||
/** 拉列表:reset=true 重置第 1 页,false 追加下一页 */
|
||
async function loadList(reset: boolean) {
|
||
if (loading.value) return;
|
||
loading.value = true;
|
||
if (reset) {
|
||
page.value = 1;
|
||
loadError.value = false;
|
||
}
|
||
try {
|
||
const result: any = await BaseApi.getMsgCenterList(currentParams({ page: page.value }));
|
||
const list: MsgRow[] = (result && result.list) || [];
|
||
total.value = (result && result.total) || 0;
|
||
rows.value = reset ? list : rows.value.concat(list);
|
||
if (reset && list.length === 0 && total.value > 0) {
|
||
// 服务端筛选参数被旧版 UserMsg 忽略时 total 与 list 不匹配的兜底展示
|
||
total.value = list.length;
|
||
}
|
||
} catch (e) {
|
||
loadError.value = true;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
/** 滚动到底自动加载下一页 */
|
||
function onScroll() {
|
||
const el = refMsgBody.value;
|
||
if (!el || loading.value || !hasMore.value) return;
|
||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 60) {
|
||
page.value += 1;
|
||
loadList(false);
|
||
}
|
||
}
|
||
|
||
/** 左栏计数一次拉齐(未读总数进 store 与铃铛同源;按应用消息计数一并拉取) */
|
||
async function loadSummary() {
|
||
try {
|
||
const [s, byApp] = await Promise.all([
|
||
BaseApi.getMsgCenterSummary(),
|
||
BaseApi.getMsgCenterCountByApp().catch(() => null),
|
||
]);
|
||
if (s) {
|
||
store.setUnreadCount(s.unreadCount);
|
||
catCounts.value = {
|
||
sysTotal: (s as any).sysTotal,
|
||
bizTotal: (s as any).bizTotal,
|
||
approvalTotal: (s as any).approvalTotal,
|
||
total: (s as any).total,
|
||
readCount: (s as any).readCount,
|
||
};
|
||
}
|
||
const map: Record<number, { total: number; unread: number }> = {};
|
||
((byApp as any) || []).forEach((e: any) => {
|
||
if (e && e.appId != null) map[Number(e.appId)] = { total: Number(e.total) || 0, unread: Number(e.unread) || 0 };
|
||
});
|
||
appCountByAppId.value = map;
|
||
} catch (e) {
|
||
/* 摘要失败不打扰列表 */
|
||
}
|
||
}
|
||
|
||
function switchMode(m: "cat" | "app") {
|
||
if (mode.value === m) return;
|
||
mode.value = m;
|
||
loadList(true);
|
||
}
|
||
|
||
function onClickCat(key: string) {
|
||
if (curCat.value === key) return;
|
||
curCat.value = key;
|
||
loadList(true);
|
||
}
|
||
function onClickApp(key: string) {
|
||
if (curApp.value === key) return;
|
||
curApp.value = key;
|
||
loadList(true);
|
||
}
|
||
|
||
/** 刷新:重置第 1 页 + 摘要 */
|
||
async function reload() {
|
||
await Promise.all([loadList(true), loadSummary()]);
|
||
}
|
||
|
||
/** 搜索防抖(300ms) */
|
||
let searchTimer: any = null;
|
||
function onSearchInput() {
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(() => loadList(true), 300);
|
||
}
|
||
|
||
/** 点击卡片:未读置已读(角标同步减);消息详情弹窗本轮不做 */
|
||
async function onClickCard(m: MsgRow) {
|
||
if (isUnread(m) && m.msgId) {
|
||
try {
|
||
await BaseApi.readMsgCenter(m.msgId);
|
||
m.isRead = 1;
|
||
store.decUnread();
|
||
loadSummary();
|
||
} catch (e) {
|
||
/* 已读失败静默,不打断浏览 */
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 去处理:未读先标读,新窗口打开落地页 */
|
||
async function onJump(m: MsgRow) {
|
||
if (isUnread(m) && m.msgId) {
|
||
BaseApi.readMsgCenter(m.msgId).catch(() => undefined);
|
||
m.isRead = 1;
|
||
store.decUnread();
|
||
loadSummary();
|
||
}
|
||
if (m.jumpUrl) window.open(m.jumpUrl, "_blank");
|
||
}
|
||
|
||
/** 全部已读:列表全部置灰 + 角标清零 + 计数刷新 */
|
||
async function readAll() {
|
||
try {
|
||
await BaseApi.readMsgCenterAll();
|
||
rows.value.forEach(m => (m.isRead = 1));
|
||
store.setUnreadCount(0);
|
||
loadSummary();
|
||
} catch (e) {
|
||
/* 失败保留现状 */
|
||
}
|
||
}
|
||
|
||
// ===== 审批直批(共享弹窗 MsgApprovalModal)=====
|
||
const refApproval = ref<any>();
|
||
function openApproval(m: MsgRow, action: "approve" | "reject") {
|
||
refApproval.value?.open({ msgId: m.msgId, title: m.title }, action);
|
||
}
|
||
/** 直批成功:该消息置已读(后端视角待办已消化)并刷新列表与计数 */
|
||
function onApprovalHandled(msgId: number) {
|
||
const row = rows.value.find(m => m.msgId === msgId);
|
||
if (row && isUnread(row)) {
|
||
row.isRead = 1;
|
||
store.decUnread();
|
||
}
|
||
loadList(true);
|
||
loadSummary();
|
||
}
|
||
|
||
// ===== WS 联动:新消息到达(Header 广播)→ 刷新第一页与计数 =====
|
||
function onWsNewMessage() {
|
||
loadList(true);
|
||
loadSummary();
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadList(true);
|
||
loadSummary();
|
||
loadApps();
|
||
mittBus.on("msgCenter:newMessage", onWsNewMessage);
|
||
});
|
||
onUnmounted(() => {
|
||
mittBus.off("msgCenter:newMessage", onWsNewMessage);
|
||
clearTimeout(searchTimer);
|
||
});
|
||
|
||
function typeName(t: number) {
|
||
return t === 2 ? "审批待办" : t === 1 ? "业务提醒" : "系统通知";
|
||
}
|
||
function typeLabel(t: number) {
|
||
return t === 2 ? "审批" : t === 1 ? "提醒" : "系统";
|
||
}
|
||
function typeIcon(t: number) {
|
||
return t === 2 ? "⏳" : t === 1 ? "📋" : "🔔";
|
||
}
|
||
/** 时间格式:刚刚 / N 分钟前 / N 小时前 / 昨天 HH:mm / MM-DD(sendTime 绝对时间 yyyy-MM-dd HH:mm:ss) */
|
||
function fmtTime(sendTime?: string) {
|
||
if (!sendTime) return "";
|
||
const d = new Date(String(sendTime).replace(" ", "T"));
|
||
if (isNaN(d.getTime())) return sendTime;
|
||
const diffMin = Math.floor((Date.now() - d.getTime()) / 60000);
|
||
const pad = (n: number) => String(n).padStart(2, "0");
|
||
if (diffMin < 1) return "刚刚";
|
||
if (diffMin < 60) return `${diffMin} 分钟前`;
|
||
if (diffMin < 1440) return `${Math.floor(diffMin / 60)} 小时前`;
|
||
if (diffMin < 2880) return `昨天 ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
/* 壳:透明,不平铺白,壁纸透出(B 方案)。
|
||
padding 与 .set-shell / .syssetting-wrap 对齐(15px 四周),overflow:hidden 同 .set-shell。 */
|
||
.msg-shell {
|
||
display: flex;
|
||
gap: 16px; /* ★ 两卡间隔 —— 必须加在两卡的直接父容器(本容器)上 */
|
||
height: calc(100% - var(--headerH));
|
||
padding: 15px;
|
||
overflow: hidden;
|
||
background: transparent;
|
||
border: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
/* 左卡:分类 / 应用导航(宽度与 .set-nav 统一为 216px) */
|
||
.msg-side {
|
||
width: 216px;
|
||
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;
|
||
cursor: pointer;
|
||
&: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.mbiz::before {
|
||
background: #e9b954;
|
||
}
|
||
&.unread.mappr::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;
|
||
}
|
||
}
|
||
|
||
/* 滚动分页脚标(加载中 / 没有更多) */
|
||
.loading-more {
|
||
text-align: center;
|
||
padding: 10px 0 14px;
|
||
font-size: 12px;
|
||
color: #b3b3b3;
|
||
}
|
||
</style>
|