Bläddra i källkod

完成消息通知页面重构

yuanmingze 2 månader sedan
förälder
incheckning
9979936893

+ 1 - 3
components.d.ts

@@ -19,14 +19,12 @@ declare module 'vue' {
     WdFormItem: typeof import('@wot-ui/ui/components/wd-form-item/wd-form-item.vue')['default']
     WdIcon: typeof import('@wot-ui/ui/components/wd-icon/wd-icon.vue')['default']
     WdInput: typeof import('@wot-ui/ui/components/wd-input/wd-input.vue')['default']
-    WdMessageBox: typeof import('@wot-ui/ui/components/wd-message-box/wd-message-box.vue')['default']
+    WdLoading: typeof import('@wot-ui/ui/components/wd-loading/wd-loading.vue')['default']
     WdNavbar: typeof import('@wot-ui/ui/components/wd-navbar/wd-navbar.vue')['default']
     WdNoticeBar: typeof import('@wot-ui/ui/components/wd-notice-bar/wd-notice-bar.vue')['default']
     WdPicker: typeof import('@wot-ui/ui/components/wd-picker/wd-picker.vue')['default']
     WdPopup: typeof import('@wot-ui/ui/components/wd-popup/wd-popup.vue')['default']
     WdProgress: typeof import('@wot-ui/ui/components/wd-progress/wd-progress.vue')['default']
-    WdRadio: typeof import('@wot-ui/ui/components/wd-radio/wd-radio.vue')['default']
-    WdRadioGroup: typeof import('@wot-ui/ui/components/wd-radio-group/wd-radio-group.vue')['default']
     WdSearch: typeof import('@wot-ui/ui/components/wd-search/wd-search.vue')['default']
     WdTag: typeof import('@wot-ui/ui/components/wd-tag/wd-tag.vue')['default']
     WdToast: typeof import('@wot-ui/ui/components/wd-toast/wd-toast.vue')['default']

+ 237 - 0
src/pages-mine/notification/detail.vue

@@ -0,0 +1,237 @@
+<template>
+  <view class="notification-detail-page">
+    <view v-if="loading" class="loading-state">
+      <wd-loading size="36rpx" />
+      <text class="loading-text">加载中</text>
+    </view>
+
+    <view v-else-if="!msgDetail" class="empty-state">
+      <wd-empty icon="no-content" tip="暂无通知内容" />
+    </view>
+
+    <view v-else class="detail-card">
+      <view class="detail-header">
+        <view class="icon-box">
+          <wd-icon name="notification" size="24px"></wd-icon>
+        </view>
+
+        <view class="header-content">
+          <text class="title">{{ msgDetail.title || '消息通知' }}</text>
+          <text v-if="msgDetail.sendTime" class="time">{{ msgDetail.sendTime }}</text>
+        </view>
+      </view>
+
+      <view class="divider" />
+
+      <view class="content">
+        <!-- #ifdef MP-WEIXIN -->
+        <rich-text v-if="safeContent" class="rich-content" space="nbsp" :nodes="safeContent" />
+        <!-- #endif -->
+
+        <!-- #ifndef MP-WEIXIN -->
+        <rich-text v-if="safeContent" class="rich-content" :nodes="safeContent" />
+        <!-- #endif -->
+
+        <view v-else class="empty-content">
+          <text>暂无内容</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+
+import { onLoad } from '@dcloudio/uni-app'
+
+import type { NotificationRecord } from '@/services/modules/mine/notification'
+import { internalMsgReadApi } from '@/services/modules/mine/notification'
+
+const loading = ref(false)
+const msgDetail = ref<NotificationRecord | null>(null)
+
+const safeContent = computed(() => {
+  return formatRichTextContent(msgDetail.value?.content ?? '')
+})
+
+const formatRichTextContent = (html: string): string => {
+  if (!html) return ''
+
+  return html
+    .replace(/<style[\s\S]*?<\/style>/gi, '')
+    .replace(/<script[\s\S]*?<\/script>/gi, '')
+    .replace(/<img\b([^>]*)>/gi, (_match: string, attrs: string) => {
+      const normalizedAttrs = attrs
+        .replace(/\swidth=(['"]).*?\1/gi, '')
+        .replace(/\sheight=(['"]).*?\1/gi, '')
+        .replace(/\sstyle=(['"]).*?\1/gi, '')
+
+      return `<img${normalizedAttrs} style="max-width:100%;height:auto;display:block;margin:16px auto;" />`
+    })
+}
+
+const getMsgDetail = async (id: number): Promise<void> => {
+  loading.value = true
+
+  try {
+    const res = await internalMsgReadApi({
+      id,
+      sendType: 2,
+    })
+
+    if (res.code !== 0) {
+      uni.showToast({
+        title: res.msg || '获取消息详情失败',
+        icon: 'none',
+      })
+      return
+    }
+
+    msgDetail.value = res.data
+  } catch {
+    uni.showToast({
+      title: '获取消息详情失败',
+      icon: 'none',
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+onLoad((options) => {
+  const id = Number(options?.id)
+
+  if (!Number.isFinite(id) || id <= 0) {
+    uni.showToast({
+      title: '消息参数错误',
+      icon: 'none',
+    })
+    return
+  }
+
+  void getMsgDetail(id)
+})
+</script>
+
+<style lang="scss" scoped>
+.notification-detail-page {
+  min-height: 100vh;
+  box-sizing: border-box;
+  padding: 24rpx;
+  background: #f6f7fb;
+}
+
+.detail-card {
+  box-sizing: border-box;
+  width: 100%;
+  padding: 32rpx;
+  background: #ffffff;
+  border: 1rpx solid rgba(230, 234, 242, 0.95);
+  border-radius: 28rpx;
+  box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.045);
+}
+
+.detail-header {
+  display: flex;
+  align-items: flex-start;
+}
+
+.icon-box {
+  flex: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 72rpx;
+  height: 72rpx;
+  margin-right: 22rpx;
+  color: #2f80ed;
+  background: #eef5ff;
+  border-radius: 22rpx;
+}
+
+.header-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.title {
+  display: block;
+  color: #111827;
+  font-size: 34rpx;
+  font-weight: 700;
+  line-height: 48rpx;
+  word-break: break-word;
+}
+
+.time {
+  display: block;
+  margin-top: 10rpx;
+  color: #98a2b3;
+  font-size: 24rpx;
+  line-height: 34rpx;
+}
+
+.divider {
+  width: 100%;
+  height: 1rpx;
+  margin: 30rpx 0;
+  background: #eef0f4;
+}
+
+.content {
+  color: #374151;
+  font-size: 30rpx;
+  line-height: 1.8;
+  word-break: break-word;
+}
+
+.rich-content {
+  display: block;
+  width: 100%;
+  color: #374151;
+  font-size: 30rpx;
+  line-height: 1.8;
+  word-break: break-word;
+  overflow-wrap: break-word;
+}
+
+.empty-content {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 220rpx;
+  color: #98a2b3;
+  font-size: 28rpx;
+}
+
+.loading-state,
+.empty-state {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 72vh;
+}
+
+.loading-state {
+  gap: 12rpx;
+}
+
+.loading-text {
+  color: #98a2b3;
+  font-size: 26rpx;
+}
+
+:deep(rich-text) {
+  max-width: 100%;
+  word-break: break-word;
+  white-space: normal;
+  overflow-wrap: break-word;
+}
+
+:deep(img) {
+  max-width: 100%;
+  height: auto;
+  display: block;
+}
+</style>

+ 334 - 0
src/pages-mine/notification/index.vue

@@ -0,0 +1,334 @@
+<template>
+  <view class="notification-page">
+    <view v-if="isEmpty" class="empty-state">
+      <wd-empty icon="no-content" tip="暂无消息" />
+    </view>
+
+    <view v-else class="notification-list">
+      <view
+        v-for="item in notificationList"
+        :key="item.id"
+        class="notification-card"
+        :class="{ 'is-unread': item.read === 0 }"
+        @click="handleDetail(item.id)"
+      >
+        <view class="icon-box">
+          <wd-icon name="notification" size="24px"></wd-icon>
+          <view v-if="item.read === 0" class="unread-dot" />
+        </view>
+
+        <view class="content-box">
+          <view class="title-row">
+            <text class="title">{{ item.title || '消息通知' }}</text>
+            <text class="time">{{ item.sendTime }}</text>
+          </view>
+
+          <text class="desc">{{ item.text }}</text>
+        </view>
+      </view>
+
+      <view v-if="loading" class="load-state">
+        <wd-loading size="30rpx" />
+        <text class="load-text">加载中</text>
+      </view>
+
+      <view v-else-if="finished && notificationList.length > 0" class="load-state">
+        <text class="load-text">没有更多了</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+
+import { onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app'
+
+import type { NotificationRecord } from '@/services/modules/mine/notification'
+import { getInternalMsgApi } from '@/services/modules/mine/notification'
+
+type NotificationViewRecord = NotificationRecord & {
+  text: string
+}
+
+const page = ref({
+  current: 1,
+  size: 10,
+  total: 0,
+})
+
+const loading = ref(false)
+const finished = ref(false)
+const initialized = ref(false)
+const notificationList = ref<NotificationViewRecord[]>([])
+
+const isEmpty = computed(() => {
+  return initialized.value && !loading.value && notificationList.value.length === 0
+})
+
+const resetList = (): void => {
+  page.value.current = 1
+  page.value.total = 0
+  finished.value = false
+  initialized.value = false
+  notificationList.value = []
+}
+
+const safeCodePointToString = (codePoint: number): string => {
+  if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
+    return ''
+  }
+
+  return String.fromCodePoint(codePoint)
+}
+
+const decodeHtmlEntitiesOnce = (value: string): string => {
+  return value
+    .replace(/&#x([0-9a-fA-F]+);/g, (_, hex: string) => {
+      return safeCodePointToString(Number.parseInt(hex, 16))
+    })
+    .replace(/&#(\d+);/g, (_, decimal: string) => {
+      return safeCodePointToString(Number.parseInt(decimal, 10))
+    })
+    .replace(/&nbsp;/gi, ' ')
+    .replace(/&amp;/gi, '&')
+    .replace(/&lt;/gi, '<')
+    .replace(/&gt;/gi, '>')
+    .replace(/&quot;/gi, '"')
+    .replace(/&apos;/gi, "'")
+    .replace(/&#39;/gi, "'")
+}
+
+const decodeHtmlEntities = (value: string): string => {
+  if (!value) return ''
+
+  return decodeHtmlEntitiesOnce(decodeHtmlEntitiesOnce(value))
+}
+
+const getPreviewText = (html: string, maxLength = 80): string => {
+  if (!html) return ''
+
+  const hasImage = /<img[\s\S]*?>/i.test(html)
+
+  const rawText = html
+    .replace(/<style[\s\S]*?<\/style>/gi, '')
+    .replace(/<script[\s\S]*?<\/script>/gi, '')
+    .replace(/<br\s*\/?>/gi, ' ')
+    .replace(/<\/p>/gi, ' ')
+    .replace(/<[^>]+>/g, '')
+
+  const text = decodeHtmlEntities(rawText).replace(/\s+/g, ' ').trim()
+
+  if (text) {
+    return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text
+  }
+
+  return hasImage ? '【图片】' : '暂无内容'
+}
+
+const normalizeRecord = (record: NotificationRecord): NotificationViewRecord => {
+  return {
+    ...record,
+    text: getPreviewText(record.content),
+  }
+}
+
+const getNotificationList = async (): Promise<void> => {
+  if (loading.value || finished.value) return
+
+  loading.value = true
+
+  try {
+    const res = await getInternalMsgApi({
+      current: page.value.current,
+      size: page.value.size,
+      sendType: 2,
+    })
+
+    if (res.code !== 0) {
+      uni.showToast({
+        title: res.msg || '获取消息列表失败',
+        icon: 'none',
+      })
+      return
+    }
+
+    const records = res.data.records.map(normalizeRecord)
+
+    notificationList.value =
+      page.value.current === 1 ? records : notificationList.value.concat(records)
+
+    page.value.total = res.data.total
+
+    finished.value =
+      notificationList.value.length >= res.data.total || records.length < page.value.size
+  } catch {
+    uni.showToast({
+      title: '获取消息列表失败',
+      icon: 'none',
+    })
+  } finally {
+    loading.value = false
+    initialized.value = true
+    uni.stopPullDownRefresh()
+  }
+}
+
+const handleDetail = (id: number): void => {
+  uni.navigateTo({
+    url: `/pages-mine/notification/detail?id=${id}`,
+  })
+}
+
+onShow(() => {
+  resetList()
+  void getNotificationList()
+})
+
+onReachBottom(() => {
+  if (loading.value || finished.value) return
+
+  page.value.current += 1
+  void getNotificationList()
+})
+
+onPullDownRefresh(() => {
+  resetList()
+  void getNotificationList()
+})
+</script>
+
+<style lang="scss" scoped>
+.notification-page {
+  min-height: 100vh;
+  box-sizing: border-box;
+  padding: 24rpx 24rpx 48rpx;
+  background: #f6f7fb;
+}
+
+.notification-list {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.notification-card {
+  display: flex;
+  align-items: flex-start;
+  box-sizing: border-box;
+  width: 100%;
+  padding: 28rpx;
+  background: #ffffff;
+  border: 1rpx solid rgba(230, 234, 242, 0.95);
+  border-radius: 24rpx;
+  box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.045);
+
+  &.is-unread {
+    border-color: rgba(47, 128, 237, 0.18);
+    box-shadow: 0 10rpx 28rpx rgba(47, 128, 237, 0.08);
+
+    .title {
+      color: #111827;
+      font-weight: 700;
+    }
+  }
+
+  &:active {
+    transform: scale(0.985);
+    background: #f9fafc;
+  }
+}
+
+.icon-box {
+  position: relative;
+  flex: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 72rpx;
+  height: 72rpx;
+  margin-right: 22rpx;
+  color: #2f80ed;
+  background: #eef5ff;
+  border-radius: 22rpx;
+}
+
+.unread-dot {
+  position: absolute;
+  top: -4rpx;
+  right: -4rpx;
+  width: 16rpx;
+  height: 16rpx;
+  background: #ff3b30;
+  border: 4rpx solid #ffffff;
+  border-radius: 50%;
+}
+
+.content-box {
+  flex: 1;
+  width: 0;
+  min-width: 0;
+}
+
+.title-row {
+  display: flex;
+  align-items: center;
+  width: 100%;
+  gap: 18rpx;
+}
+
+.title {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  color: #1f2937;
+  font-size: 32rpx;
+  font-weight: 600;
+  line-height: 44rpx;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.time {
+  flex: none;
+  max-width: 260rpx;
+  overflow: hidden;
+  color: #98a2b3;
+  font-size: 24rpx;
+  line-height: 34rpx;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.desc {
+  display: block;
+  width: 100%;
+  margin-top: 16rpx;
+  overflow: hidden;
+  color: #667085;
+  font-size: 28rpx;
+  line-height: 40rpx;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.load-state {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 12rpx;
+  height: 88rpx;
+}
+
+.load-text {
+  color: #9aa3b2;
+  font-size: 24rpx;
+}
+
+.empty-state {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 72vh;
+}
+</style>

+ 14 - 0
src/pages.json

@@ -77,6 +77,20 @@
             "navigationStyle": "default"
           }
         },
+        {
+          "path": "notification/index",
+          "style": {
+            "navigationBarTitleText": "消息通知",
+            "navigationStyle": "default"
+          }
+        },
+        {
+          "path": "notification/detail",
+          "style": {
+            "navigationBarTitleText": "消息通知详情",
+            "navigationStyle": "default"
+          }
+        },
         {
           "path": "compliance-education/index",
           "style": {

+ 26 - 2
src/pages/mine/index.vue

@@ -121,14 +121,17 @@
             <image class="common-grid-icon" :src="item.icon" mode="aspectFit" />
             <view class="common-grid-text">{{ item.txt }}</view>
           </view>
-          <view class="common-grid-item common-grid-item-message">
+          <view
+            class="common-grid-item common-grid-item-message"
+            @click="navigateTo('/pages-mine/notification/index')"
+          >
             <view class="common-grid-icon-wrap">
               <image
                 class="common-grid-icon"
                 src="https://yy-cloud-oss-public.oss-cn-beijing.aliyuncs.com/image/message-icon.png"
                 mode="aspectFit"
               />
-              <view class="common-grid-badge"></view>
+              <view class="common-grid-badge" v-if="hasUnreadNotification"></view>
             </view>
             <view class="common-grid-text">消息通知</view>
           </view>
@@ -159,6 +162,10 @@
 <script setup lang="ts">
 import { computed, ref } from 'vue'
 
+import { onLoad } from '@dcloudio/uni-app'
+
+import { getInternalMsgCountUnreadApi } from '@/services/modules/mine/common'
+
 import { useUserStore } from '@/stores/modules/user'
 
 import { functionList, menuList, rolesList } from './mine.config'
@@ -170,6 +177,23 @@ const currentUserInfo = computed(() => userStore.currentUserInfo)
 
 const isBlacklisted = computed(() => userStore.isBlacklisted)
 
+onLoad(() => {
+  if (!isLoggedIn.value) return
+  getInternalMsgCountUnread()
+})
+
+const hasUnreadNotification = ref(false)
+const getInternalMsgCountUnread = async () => {
+  try {
+    const res = await getInternalMsgCountUnreadApi()
+    if (res.code === 0) {
+      hasUnreadNotification.value = res.data === 0 ? false : true
+    }
+  } catch (error) {
+    console.error('获取未读消息数量失败:', error)
+  }
+}
+
 const navigateToLogin = () => {
   uni.navigateTo({
     url: '/pages/login/index',

+ 7 - 0
src/services/modules/mine/common/index.ts

@@ -1,6 +1,7 @@
 import http from '../../../index'
 import type {
   DeptRegulationsListResponse,
+  InternalMsgCountUnreadResponse,
   SaveFeedbackRequest,
   SubjectLocationAgreementRequest,
   SubjectLocationAgreementResponse,
@@ -23,3 +24,9 @@ export const saveFeedbackApi = (data: SaveFeedbackRequest) => {
 export const getDeptRegulationsListApi = () => {
   return http.get<DeptRegulationsListResponse>('/admin/dept/dept-regulations/list')
 }
+
+export const getInternalMsgCountUnreadApi = () => {
+  return http.get<InternalMsgCountUnreadResponse>('/admin/internal-msg/count-unread', {
+    sendType: 2,
+  })
+}

+ 2 - 0
src/services/modules/mine/common/type.d.ts

@@ -35,3 +35,5 @@ export interface ComplianceEducationFile {
   fileName: string
   url: string
 }
+
+export type InternalMsgCountUnreadResponse = number

+ 10 - 0
src/services/modules/mine/notification/index.ts

@@ -0,0 +1,10 @@
+import http from '../../../index'
+import type { NotificationListRequest, NotificationListResponse, NotificationRecord } from './type'
+
+export const getInternalMsgApi = (data: NotificationListRequest) => {
+  return http.get<NotificationListResponse>('/admin/internal-msg/user-msg', data)
+}
+
+export const internalMsgReadApi = (data: { id: number; sendType: number }) => {
+  return http.get<NotificationRecord>('/admin/internal-msg/read', data)
+}

+ 27 - 0
src/services/modules/mine/notification/type.d.ts

@@ -0,0 +1,27 @@
+export interface NotificationListRequest {
+  current: number
+  size: number
+  sendType: number
+}
+
+export interface NotificationListResponse {
+  records: NotificationRecord[]
+  total: number
+  size: number
+  current: number
+  pages: number
+  orders: unknown[]
+  optimizeCountSql: boolean
+  searchCount: boolean
+  hitCount: boolean
+}
+
+export interface NotificationRecord {
+  id: number
+  title: string
+  content: string
+  read: 0 | 1
+  sendTime: string
+}
+
+