Files
UserhomeFront/src/views/msg/index.vue
T
chenxing 297934f862
build / build (push) Successful in 1m59s
feat(msg): 「按应用」清单接真实数据——GetUserApp 应用全集 + 按应用未读数角标
- 应用清单改与桌面网格同源 GetUserApp(BoaApp 自带 appKey/name):原实现读 store.getFlattenAppList,但 setApps 无调用方、列表恒空,且 MenuBar 本身无 appKey 字段——「按应用」此前只剩「全部」一行
- 角标接新代理端点 msgCenter/unreadCountByApp({appKey: 未读数}),未读多的应用排前,替代原 summary.todos 待办数语义
- 卡片 appName 缺失降级改同源匹配用户应用清单
2026-10-08 12:00:05 +08:00

866 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 用户应用全集;角标=unreadCountByApp 按应用未读数) -->
<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.appKey"
:class="['cat', { active: curApp === a.appKey }]"
@click="onClickApp(a.appKey)"
>
<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;
appKey?: string | null;
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 无 summary 端点时分类字段缺省 → 显示 —) */
const catCounts = ref<{ sysUnread?: number; bizUnread?: number; approvalUnread?: 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 缺失降级:按 appKey 匹配用户应用清单(与桌面网格同源),再退类型标签 */
function typeAppName(m: MsgRow) {
if (m.appKey) {
const hit = userApps.value.find(a => a.appKey === String(m.appKey));
if (hit && hit.name) return hit.name;
}
return typeName(m.msgType);
}
/** 分类导航(计数走 summary:分类未读数;「已读」= 已读总数) */
const catList = computed(() => [
{ key: "all", name: "全部消息", color: "#5979ea", cnt: catCounts.value.total == null ? "—" : catCounts.value.total },
{ key: "sys", name: "系统通知", color: "#5979ea", cnt: catCounts.value.sysUnread == null ? "—" : catCounts.value.sysUnread },
{ key: "biz", name: "业务提醒", color: "#e9b954", cnt: catCounts.value.bizUnread == null ? "—" : catCounts.value.bizUnread },
{ key: "appr", name: "审批待办", color: "#e8684a", cnt: catCounts.value.approvalUnread == null ? "—" : catCounts.value.approvalUnread },
{ key: "read", name: "已读", color: "#c0c6ce", cnt: catCounts.value.readCount == null ? "—" : catCounts.value.readCount },
]);
/** 应用导航:清单=用户应用全集(GetUserApp,BoaApp 自带 appKey/name,与桌面网格同源);角标=按应用未读数 */
const userApps = ref<{ appKey: string; name: string }[]>([]);
const unreadByApp = ref<Record<string, number>>({});
const appList = computed(() =>
userApps.value
.map(a => ({ appKey: a.appKey, name: a.name, cnt: unreadByApp.value[a.appKey] || 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.appKey != null)
.map((a: any) => ({ appKey: String(a.appKey), 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 => a.appKey === curApp.value);
return hit ? hit.name : "全部应用消息";
});
/** 当前筛选条件 → 服务端参数(筛选语义归服务端,本地不过滤) */
function currentParams(extra: { page: number }) {
const params: { page: number; count: number; msgType?: number; appKey?: 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.appKey = 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.getMsgCenterUnreadCountByApp().catch(() => null),
]);
if (s) {
store.setUnreadCount(s.unreadCount);
catCounts.value = {
sysUnread: (s as any).sysUnread,
bizUnread: (s as any).bizUnread,
approvalUnread: (s as any).approvalUnread,
total: (s as any).total,
readCount: (s as any).readCount,
};
}
const map: Record<string, number> = {};
if (byApp && typeof byApp === "object") {
Object.entries(byApp).forEach(([k, v]) => (map[k] = Number(v) || 0));
}
unreadByApp.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>