zhangchen 2 mēneši atpakaļ
vecāks
revīzija
f289fc81ce

+ 103 - 0
src/api/modules/headerNotice.ts

@@ -0,0 +1,103 @@
+import httpApi from "@/api/indexApi";
+
+/** 通知列表请求参数(与原有通知页面逻辑一致) */
+export interface GetNoticeListParams {
+  pageNum: number;
+  pageSize: number;
+  receiverId: string;
+  noticeType: number;
+}
+
+/** 通知列表项 */
+export interface NoticeRecord {
+  id: number;
+  title?: string;
+  createdTime?: string;
+  context?: string;
+  content?: string;
+  isRead?: boolean;
+}
+
+/** 通知列表响应 */
+export interface GetNoticeListRes {
+  records?: NoticeRecord[];
+  list?: NoticeRecord[];
+  total?: number;
+}
+
+/**
+ * 头部通知弹窗 - 系统通知列表(独立接口,不修改原有 homeEntry)
+ * GET /alienStorePlatform/notice/getNoticeList
+ */
+export const getNoticeListForHeader = (params: GetNoticeListParams) => {
+  return httpApi.get<GetNoticeListRes>(`/alienStorePlatform/notice/getNoticeList`, params, {
+    loading: false
+  });
+};
+
+/**
+ * 标记通知已读(alien-store 服务)
+ * GET /alienStore/notice/readNoticeById?id=xxx
+ */
+export const readNoticeById = (params: { id: number | string }) => {
+  return httpApi.get<unknown>(`/alienStore/notice/readNoticeById`, params, {
+    loading: false
+  });
+};
+
+/**
+ * 按类型查询未读通知数量
+ * GET /alienStore/notice/countUnreadByType?noticeType=0|1|2&receiverId=xxx(alien-store)
+ * noticeType: 0-与我相关 1-系统通知 2-订单提醒
+ */
+export const getCountUnreadByType = (params: { noticeType: number; receiverId: string }) => {
+  return httpApi.get<number>(`/alienStore/notice/countUnreadByType`, params, {
+    loading: false
+  });
+};
+
+/** 未关注人消息单条(getNoFriendMessage 返回 data 项) */
+export interface NoFriendMessageItem {
+  id?: number;
+  senderId?: string | null;
+  senderName?: string | null;
+  receiverId?: string | null;
+  content?: string;
+  type?: string;
+  isRead?: number;
+  createdTime?: string;
+  userName?: string | null;
+  userImage?: string | null;
+  storeImg?: string | null;
+  senderImg?: string | null;
+  notReadCount?: number;
+  [key: string]: any;
+}
+
+/**
+ * 未关注人消息列表
+ * GET /message/getNoFriendMessage?receiverId=xxx(alien-store)
+ */
+export const getNoFriendMessage = (params: { receiverId: string }) => {
+  return httpApi.get<NoFriendMessageItem | NoFriendMessageItem[]>(`/alienStore/message/getNoFriendMessage`, params, {
+    loading: false
+  });
+};
+
+/**
+ * 消息列表(返回结构与 getNoFriendMessage 一致)
+ * GET /message/getMessageList?receiverId=xxx&friendType=0
+ */
+export const getMessageList = (params: { receiverId: string; friendType?: number }) => {
+  const { receiverId, friendType = 0 } = params;
+  return httpApi.get<NoFriendMessageItem | NoFriendMessageItem[]>(
+    `/alienStore/message/getMessageList`,
+    {
+      receiverId,
+      friendType
+    },
+    {
+      loading: false
+    }
+  );
+};

+ 2 - 0
src/layouts/components/Header/ToolBarRight.vue

@@ -5,6 +5,7 @@
       <Language id="language" />
       <SearchMenu id="searchMenu" />
       <ThemeSetting id="themeSetting" />
+      <NotificationBell id="notificationBell" />
       <Message id="message" />
       <Fullscreen id="fullscreen" />
     </div>
@@ -20,6 +21,7 @@ import AssemblySize from "./components/AssemblySize.vue";
 import Language from "./components/Language.vue";
 import SearchMenu from "./components/SearchMenu.vue";
 import ThemeSetting from "./components/ThemeSetting.vue";
+import NotificationBell from "./components/NotificationBell.vue";
 import Message from "./components/Message.vue";
 import Fullscreen from "./components/Fullscreen.vue";
 import Avatar from "./components/Avatar.vue";

+ 59 - 0
src/layouts/components/Header/components/NotificationBell.vue

@@ -0,0 +1,59 @@
+<template>
+  <div class="notification-bell">
+    <el-badge :value="unreadCount > 0 ? unreadCount : undefined" class="item">
+      <el-icon :size="18" class="toolBar-icon bell-icon" @click="openDialog">
+        <Bell />
+      </el-icon>
+    </el-badge>
+    <el-dialog v-model="dialogVisible" width="880px" class="notification-dialog" destroy-on-close :show-close="true" align-center>
+      <NotificationDrawerContent v-if="dialogVisible" />
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from "vue";
+import { Bell } from "@element-plus/icons-vue";
+import NotificationDrawerContent from "./NotificationDrawerContent.vue";
+
+const dialogVisible = ref(false);
+// 未读数量(先写死,后续接接口)
+const unreadCount = ref(0);
+
+function openDialog() {
+  dialogVisible.value = true;
+}
+</script>
+
+<style scoped lang="scss">
+.notification-bell {
+  display: flex;
+  align-items: center;
+  .item {
+    :deep(.el-badge__content) {
+      border: none;
+    }
+  }
+  .bell-icon {
+    color: var(--el-header-text-color);
+    cursor: pointer;
+  }
+}
+</style>
+
+<style lang="scss">
+.notification-dialog {
+  .el-dialog__header {
+    padding: 16px 20px 12px;
+    margin-right: 16px;
+  }
+  .el-dialog__body {
+    display: flex;
+    flex-direction: column;
+    min-height: 520px;
+    max-height: 75vh;
+    padding: 0 20px 20px;
+    overflow: hidden;
+  }
+}
+</style>

+ 685 - 0
src/layouts/components/Header/components/NotificationDrawerContent.vue

@@ -0,0 +1,685 @@
+<template>
+  <div class="notification-drawer-content">
+    <!-- 顶部 Tab:通知 / 消息 -->
+    <div class="drawer-tabs">
+      <div class="tab-item" :class="{ active: activeTab === 'notice' }" @click="activeTab = 'notice'">通知</div>
+      <div class="tab-item" :class="{ active: activeTab === 'message' }" @click="activeTab = 'message'">消息</div>
+    </div>
+
+    <div class="drawer-body">
+      <!-- 左侧分类:通知 Tab 与 消息 Tab 不同 -->
+      <div class="drawer-side">
+        <template v-if="activeTab === 'notice'">
+          <div
+            v-for="cat in noticeCategories"
+            :key="cat.key"
+            class="side-item"
+            :class="{ active: activeCategory === cat.key }"
+            @click="switchNoticeCategory(cat.key)"
+          >
+            <el-icon class="side-icon">
+              <component :is="cat.icon" />
+            </el-icon>
+            <span class="side-text">{{ cat.title }}</span>
+            <el-badge v-if="cat.unread > 0" :value="cat.unread" class="side-badge" />
+          </div>
+        </template>
+        <template v-else>
+          <div
+            v-for="cat in messageCategories"
+            :key="cat.key"
+            class="side-item"
+            :class="{ active: messageCategory === cat.key }"
+            @click="switchMessageCategory(cat.key)"
+          >
+            <el-icon class="side-icon">
+              <component :is="cat.icon" />
+            </el-icon>
+            <span class="side-text">{{ cat.title }}</span>
+          </div>
+        </template>
+      </div>
+
+      <!-- 右侧:通知列表 -->
+      <div v-if="activeTab === 'notice'" class="drawer-main-wrap">
+        <div class="drawer-main">
+          <div v-if="currentLoading" class="empty-tip">
+            <el-icon class="is-loading" :size="24">
+              <Loading />
+            </el-icon>
+            <span style="margin-left: 8px">加载中...</span>
+          </div>
+          <template v-else>
+            <div
+              v-for="(item, index) in currentList"
+              :key="item.id + '_' + index"
+              class="notice-card"
+              :class="{ unread: item.unread }"
+            >
+              <div class="card-row">
+                <span class="card-title">
+                  {{ item.title }}
+                  <span v-if="item.unread" class="unread-dot" />
+                </span>
+                <span class="card-date">{{ item.date }}</span>
+              </div>
+              <div class="card-content">
+                {{ item.content }}
+              </div>
+              <div class="card-actions">
+                <el-button size="small" @click="handleViewDetail(item)"> 查看详情 </el-button>
+                <el-button size="small" @click="handleDelete(item, index)"> 删除 </el-button>
+              </div>
+            </div>
+            <div v-if="currentList.length === 0" class="empty-tip">暂无数据</div>
+          </template>
+        </div>
+        <div v-if="currentPagination.total > 0" class="pagination-wrap">
+          <el-pagination
+            :current-page="currentPagination.pageNum"
+            :page-size="currentPagination.pageSize"
+            :page-sizes="[10, 20, 50, 100]"
+            :total="currentPagination.total"
+            layout="total, sizes, prev, pager, next, jumper"
+            small
+            @size-change="handleSizeChange"
+            @current-change="handlePageChange"
+          />
+        </div>
+      </div>
+
+      <!-- 右侧:消息列表(如图:头像 + 发送者 + 红点 + 内容 + 日期 + 删除) -->
+      <div v-else class="drawer-main-wrap">
+        <div class="drawer-main">
+          <div v-if="messageLoading" class="empty-tip">
+            <el-icon class="is-loading" :size="24">
+              <Loading />
+            </el-icon>
+            <span style="margin-left: 8px">加载中...</span>
+          </div>
+          <template v-else>
+            <div v-for="(item, index) in currentMessageList" :key="item.id + '_' + index" class="message-card">
+              <div class="message-avatar">
+                <el-avatar :size="40" :src="item.avatar">
+                  <el-icon><UserFilled /></el-icon>
+                </el-avatar>
+              </div>
+              <div class="message-body">
+                <div class="message-row">
+                  <span class="message-sender">{{ item.senderName }}</span>
+                  <span class="message-date">{{ item.date }}</span>
+                </div>
+                <div class="message-content">
+                  {{ item.content }}
+                </div>
+                <div class="message-actions">
+                  <el-button size="small" type="default" @click="handleMessageDelete(item, index)"> 删除 </el-button>
+                </div>
+              </div>
+            </div>
+            <div v-if="currentMessageList.length === 0" class="empty-tip">暂无数据</div>
+          </template>
+        </div>
+      </div>
+    </div>
+
+    <!-- 详情弹窗 -->
+    <el-dialog v-model="detailVisible" :title="currentDetail?.title" width="500px">
+      <div class="detail-dialog-content">
+        {{ currentDetail?.content }}
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, watch } from "vue";
+import { House, List, User, Loading, Message, UserFilled } from "@element-plus/icons-vue";
+import { localGet } from "@/utils";
+import {
+  getNoticeListForHeader,
+  readNoticeById,
+  getCountUnreadByType,
+  getNoFriendMessage,
+  getMessageList
+} from "@/api/modules/headerNotice";
+import type { NoFriendMessageItem } from "@/api/modules/headerNotice";
+
+interface NoticeItem {
+  id: string;
+  title: string;
+  content: string;
+  date: string;
+  unread: boolean;
+}
+
+interface MessageItem {
+  id: string;
+  senderName: string;
+  content: string;
+  date: string;
+  unread: boolean;
+  avatar?: string;
+}
+
+const activeTab = ref<"notice" | "message">("notice");
+const activeCategory = ref("system");
+const messageCategory = ref("unfollowed");
+
+// 通知 Tab 分类
+const noticeTypeByKey: Record<string, number> = {
+  system: 1,
+  order: 2,
+  related: 0
+};
+const noticeCategories = ref([
+  { key: "system", title: "系统通知", icon: House, unread: 0 },
+  { key: "order", title: "订单提醒", icon: List, unread: 0 },
+  { key: "related", title: "与我相关", icon: User, unread: 0 }
+]);
+
+// 消息 Tab 分类(如图:未关注人消息、消息列表)
+const messageCategories = ref([
+  { key: "unfollowed", title: "未关注人消息", icon: User, unread: 0 },
+  { key: "messageList", title: "消息列表", icon: Message, unread: 0 }
+]);
+
+const listByCategory = ref<Record<string, NoticeItem[]>>({
+  system: [],
+  order: [],
+  related: []
+});
+
+// 消息列表数据:未关注人消息、消息列表均走接口
+const messageListByCategory = ref<Record<string, MessageItem[]>>({
+  unfollowed: [],
+  messageList: []
+});
+
+const messageLoading = ref(false);
+
+// 每个通知分类的分页与加载态
+const paginationByCategory = ref<Record<string, { pageNum: number; pageSize: number; total: number }>>({
+  system: { pageNum: 1, pageSize: 10, total: 0 },
+  order: { pageNum: 1, pageSize: 10, total: 0 },
+  related: { pageNum: 1, pageSize: 10, total: 0 }
+});
+const loadingByCategory = ref<Record<string, boolean>>({
+  system: false,
+  order: false,
+  related: false
+});
+
+const detailVisible = ref(false);
+const currentDetail = ref<NoticeItem | null>(null);
+
+const currentList = computed(() => {
+  return listByCategory.value[activeCategory.value] ?? [];
+});
+
+const currentPagination = computed(() => {
+  return paginationByCategory.value[activeCategory.value] ?? { pageNum: 1, pageSize: 10, total: 0 };
+});
+
+const currentLoading = computed(() => {
+  return loadingByCategory.value[activeCategory.value] ?? false;
+});
+
+const currentMessageList = computed(() => {
+  return messageListByCategory.value[messageCategory.value] ?? [];
+});
+
+function switchNoticeCategory(key: string) {
+  activeCategory.value = key;
+  const list = listByCategory.value[key] ?? [];
+  const loading = loadingByCategory.value[key];
+  if (list.length === 0 && !loading) {
+    paginationByCategory.value[key].pageNum = 1;
+    fetchNoticeList(key);
+  }
+}
+
+function switchMessageCategory(key: string) {
+  messageCategory.value = key;
+  if (key === "unfollowed") {
+    fetchNoFriendMessage();
+  } else if (key === "messageList") {
+    fetchMessageList();
+  }
+}
+
+// 未关注人消息:/message/getNoFriendMessage,参数 receiverId
+function mapNoFriendToMessageItem(item: NoFriendMessageItem): MessageItem {
+  const createdTime = item.createdTime ?? "";
+  const dateStr = createdTime.includes(" ") ? createdTime.split(" ")[0].replace(/-/g, "/") : createdTime.replace(/-/g, "/");
+  return {
+    id: String(item.id ?? ""),
+    senderName: item.senderName ?? item.userName ?? "",
+    content: item.content ?? "",
+    date: dateStr,
+    unread: item.isRead === 0,
+    avatar: item.userImage ?? item.senderImg ?? item.storeImg ?? undefined
+  };
+}
+
+async function fetchNoFriendMessage() {
+  const receiverId = getReceiverId();
+  if (!receiverId) {
+    messageListByCategory.value.unfollowed = [];
+    const cat = messageCategories.value.find(c => c.key === "unfollowed");
+    if (cat) cat.unread = 0;
+    return;
+  }
+  messageLoading.value = true;
+  try {
+    const res: any = await getNoFriendMessage({ receiverId: "store_" + receiverId });
+    const data = res?.data ?? res;
+    const rawList = Array.isArray(data) ? data : data ? [data] : [];
+    const list: MessageItem[] = rawList.map((item: NoFriendMessageItem) => mapNoFriendToMessageItem(item));
+    messageListByCategory.value.unfollowed = list;
+    const unreadTotal = rawList.reduce(
+      (sum: number, item: NoFriendMessageItem) => sum + (item.notReadCount ?? (item.isRead === 0 ? 1 : 0)),
+      0
+    );
+    const cat = messageCategories.value.find(c => c.key === "unfollowed");
+    if (cat) cat.unread = unreadTotal;
+  } catch (e) {
+    messageListByCategory.value.unfollowed = [];
+    const cat = messageCategories.value.find(c => c.key === "unfollowed");
+    if (cat) cat.unread = 0;
+  } finally {
+    messageLoading.value = false;
+  }
+}
+
+// 消息列表:/message/getMessageList,参数 friendType 默认 0、receiverId,返回结构与 getNoFriendMessage 一致
+async function fetchMessageList() {
+  const receiverId = getReceiverId();
+  if (!receiverId) {
+    messageListByCategory.value.messageList = [];
+    const cat = messageCategories.value.find(c => c.key === "messageList");
+    if (cat) cat.unread = 0;
+    return;
+  }
+  messageLoading.value = true;
+  try {
+    const res: any = await getMessageList({ receiverId: "store_" + receiverId, friendType: 0 });
+    const data = res?.data ?? res;
+    const rawList = Array.isArray(data) ? data : data ? [data] : [];
+    const list: MessageItem[] = rawList.map((item: NoFriendMessageItem) => mapNoFriendToMessageItem(item));
+    messageListByCategory.value.messageList = list;
+    const unreadTotal = rawList.reduce(
+      (sum: number, item: NoFriendMessageItem) => sum + (item.notReadCount ?? (item.isRead === 0 ? 1 : 0)),
+      0
+    );
+    const cat = messageCategories.value.find(c => c.key === "messageList");
+    if (cat) cat.unread = unreadTotal;
+  } catch (e) {
+    messageListByCategory.value.messageList = [];
+    const cat = messageCategories.value.find(c => c.key === "messageList");
+    if (cat) cat.unread = 0;
+  } finally {
+    messageLoading.value = false;
+  }
+}
+
+// 与原有通知页面一致的 receiverId
+function getReceiverId(): string {
+  return localGet("iphone") || localGet("geeker-user")?.userInfo?.phone || "";
+}
+
+// 与原有通知页面一致的 context 解析
+function parseContext(context: string | undefined): string {
+  if (!context) return "";
+  try {
+    const parsed = typeof context === "string" ? JSON.parse(context) : context;
+    return parsed?.message || context;
+  } catch {
+    return context;
+  }
+}
+
+// 统一请求:/alienStorePlatform/notice/getNoticeList,noticeType 系统=1、订单提醒=2、与我相关=0,分页查询
+async function fetchNoticeList(catKey: string) {
+  const receiverId = getReceiverId();
+  if (!receiverId) {
+    listByCategory.value[catKey] = [];
+    paginationByCategory.value[catKey].total = 0;
+    return;
+  }
+  const noticeType = noticeTypeByKey[catKey] ?? 1;
+  const pagination = paginationByCategory.value[catKey];
+  loadingByCategory.value[catKey] = true;
+  try {
+    const res: any = await getNoticeListForHeader({
+      pageNum: pagination.pageNum,
+      pageSize: pagination.pageSize,
+      receiverId: "store_" + receiverId,
+      noticeType
+    });
+    const data = res?.data ?? res;
+    const records = data?.records ?? data?.list ?? [];
+    const total = data?.total ?? 0;
+    const list: NoticeItem[] = records.map((item: any) => ({
+      id: String(item.id),
+      title: item.title ?? "",
+      content: parseContext(item.context ?? item.content),
+      date: item.createdTime ?? "",
+      unread: !item.isRead
+    }));
+    listByCategory.value[catKey] = list;
+    paginationByCategory.value[catKey].total = total;
+  } catch (e) {
+    listByCategory.value[catKey] = [];
+    paginationByCategory.value[catKey].total = 0;
+  } finally {
+    loadingByCategory.value[catKey] = false;
+  }
+}
+
+// 按类型拉取未读数量:noticeType 0-与我相关 1-系统通知 2-订单提醒
+async function fetchNoticeUnreadCounts() {
+  const receiverId = getReceiverId();
+  if (!receiverId) {
+    noticeCategories.value.forEach(c => (c.unread = 0));
+    return;
+  }
+  const receiverIdParam = "store_" + receiverId;
+  try {
+    const [res1, res2, res0] = await Promise.all([
+      getCountUnreadByType({ noticeType: 1, receiverId: receiverIdParam }),
+      getCountUnreadByType({ noticeType: 2, receiverId: receiverIdParam }),
+      getCountUnreadByType({ noticeType: 0, receiverId: receiverIdParam })
+    ]);
+    const systemCat = noticeCategories.value.find(c => c.key === "system");
+    const orderCat = noticeCategories.value.find(c => c.key === "order");
+    const relatedCat = noticeCategories.value.find(c => c.key === "related");
+    if (systemCat) systemCat.unread = Number((res1 as any)?.data ?? (res1 as any) ?? 0);
+    if (orderCat) orderCat.unread = Number((res2 as any)?.data ?? (res2 as any) ?? 0);
+    if (relatedCat) relatedCat.unread = Number((res0 as any)?.data ?? (res0 as any) ?? 0);
+  } catch (e) {
+    noticeCategories.value.forEach(c => (c.unread = 0));
+  }
+}
+
+// 进入通知页面时:用 countUnreadByType 刷新三个标签未读数,并拉取当前分类列表
+function refreshAllNoticeCategories() {
+  fetchNoticeUnreadCounts();
+  fetchNoticeList(activeCategory.value);
+}
+
+function handleSizeChange(size: number) {
+  const key = activeCategory.value;
+  paginationByCategory.value[key].pageSize = size;
+  paginationByCategory.value[key].pageNum = 1;
+  fetchNoticeList(key);
+}
+
+function handlePageChange(page: number) {
+  const key = activeCategory.value;
+  paginationByCategory.value[key].pageNum = page;
+  fetchNoticeList(key);
+}
+
+async function handleViewDetail(item: NoticeItem) {
+  if (item.unread) {
+    try {
+      const res: any = await readNoticeById({ id: item.id });
+      const ok = res?.code === 200 || res?.code === "200";
+      if (ok) {
+        const key = activeCategory.value;
+        const list = listByCategory.value[key] ?? [];
+        const idx = list.findIndex(i => i.id === item.id);
+        if (idx !== -1) list[idx].unread = false;
+        const cat = noticeCategories.value.find(c => c.key === key);
+        if (cat && cat.unread > 0) cat.unread -= 1;
+      }
+    } catch (e) {
+      // 仍打开详情
+    }
+  }
+  currentDetail.value = item;
+  detailVisible.value = true;
+}
+
+function handleDelete(item: NoticeItem, index: number) {
+  const key = activeCategory.value;
+  listByCategory.value[key] = currentList.value.filter((_, i) => i !== index);
+  const cat = noticeCategories.value.find(c => c.key === key);
+  if (cat && item.unread) cat.unread = Math.max(0, cat.unread - 1);
+}
+
+function handleMessageDelete(item: MessageItem, index: number) {
+  const key = messageCategory.value;
+  messageListByCategory.value[key] = currentMessageList.value.filter((_, i) => i !== index);
+  const cat = messageCategories.value.find(c => c.key === key);
+  if (cat && item.unread) cat.unread = Math.max(0, cat.unread - 1);
+}
+
+onMounted(() => {
+  refreshAllNoticeCategories();
+});
+
+watch(activeCategory, val => {
+  const list = listByCategory.value[val] ?? [];
+  const loading = loadingByCategory.value[val];
+  if (list.length === 0 && !loading) {
+    paginationByCategory.value[val].pageNum = 1;
+    fetchNoticeList(val);
+  }
+});
+
+watch(activeTab, val => {
+  if (val === "notice") {
+    refreshAllNoticeCategories();
+  } else if (val === "message") {
+    messageCategory.value = "unfollowed";
+    fetchNoFriendMessage();
+    fetchMessageList();
+  }
+});
+</script>
+
+<style scoped lang="scss">
+.notification-drawer-content {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 480px;
+}
+.drawer-tabs {
+  display: flex;
+  gap: 32px;
+  padding: 18px 0 14px;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+  .tab-item {
+    padding-bottom: 6px;
+    font-size: 16px;
+    color: var(--el-text-color-secondary);
+    cursor: pointer;
+    &.active {
+      font-weight: 600;
+      color: var(--el-color-primary);
+      border-bottom: 2px solid var(--el-color-primary);
+    }
+  }
+}
+.drawer-body {
+  display: flex;
+  flex: 1;
+  gap: 20px;
+  min-height: 0;
+  margin-top: 16px;
+}
+.drawer-side {
+  display: flex;
+  flex-shrink: 0;
+  flex-direction: column;
+  gap: 6px;
+  width: 160px;
+  padding: 12px 0;
+  background: var(--el-fill-color-lighter);
+  border-radius: 8px;
+  .side-item {
+    display: flex;
+    gap: 10px;
+    align-items: center;
+    padding: 12px 14px;
+    margin: 0 8px;
+    font-size: 14px;
+    color: var(--el-text-color-regular);
+    cursor: pointer;
+    border-radius: 6px;
+    &.active {
+      font-weight: 500;
+      color: var(--el-color-primary);
+      background: var(--el-color-primary-light-9);
+    }
+  }
+  .side-icon {
+    font-size: 18px;
+  }
+  .side-text {
+    flex: 1;
+  }
+  .side-badge {
+    :deep(.el-badge__content) {
+      border: none;
+    }
+  }
+}
+.drawer-main-wrap {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  min-width: 0;
+}
+.drawer-main {
+  flex: 1;
+  min-width: 0;
+  padding-right: 4px;
+  overflow-y: auto;
+}
+.pagination-wrap {
+  display: flex;
+  flex-shrink: 0;
+  justify-content: flex-end;
+  padding: 12px 0 0;
+  margin-top: 8px;
+  border-top: 1px solid var(--el-border-color-lighter);
+}
+.message-card {
+  display: flex;
+  gap: 14px;
+  padding: 14px 0;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+  &:last-child {
+    border-bottom: none;
+  }
+  .message-avatar {
+    flex-shrink: 0;
+  }
+  .message-body {
+    flex: 1;
+    min-width: 0;
+  }
+  .message-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 6px;
+  }
+  .message-sender {
+    display: flex;
+    gap: 6px;
+    align-items: center;
+    font-size: 14px;
+    font-weight: 500;
+    color: var(--el-text-color-primary);
+    .unread-dot {
+      flex-shrink: 0;
+      width: 6px;
+      height: 6px;
+      background: var(--el-color-danger);
+      border-radius: 50%;
+    }
+  }
+  .message-date {
+    font-size: 12px;
+    color: var(--el-text-color-secondary);
+  }
+  .message-content {
+    margin-bottom: 8px;
+    font-size: 14px;
+    line-height: 1.5;
+    color: var(--el-text-color-regular);
+  }
+  .message-actions {
+    display: flex;
+    justify-content: flex-end;
+  }
+}
+.notice-card {
+  padding: 18px 20px;
+  margin-bottom: 12px;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+  transition: all 0.2s;
+  &:last-child {
+    margin-bottom: 0;
+  }
+  .card-row {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    margin-bottom: 10px;
+  }
+  .card-title {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    font-size: 15px;
+    font-weight: 600;
+    color: var(--el-text-color-primary);
+    .unread-dot {
+      flex-shrink: 0;
+      width: 8px;
+      height: 8px;
+      background: var(--el-color-danger);
+      border-radius: 50%;
+    }
+  }
+  .card-date {
+    flex-shrink: 0;
+    font-size: 13px;
+    color: var(--el-text-color-secondary);
+  }
+  .card-content {
+    margin-bottom: 12px;
+    font-size: 14px;
+    line-height: 1.6;
+    color: var(--el-text-color-regular);
+  }
+  .card-actions {
+    display: flex;
+    gap: 10px;
+    justify-content: flex-end;
+  }
+}
+.empty-tip {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 60px 20px;
+  font-size: 15px;
+  color: var(--el-text-color-secondary);
+  text-align: center;
+}
+.detail-dialog-content {
+  padding: 10px 0;
+  font-size: 14px;
+  line-height: 1.8;
+  color: var(--el-text-color-regular);
+}
+</style>