feat: add notification feature

This commit is contained in:
luo
2026-07-29 10:25:13 +08:00
parent d115363115
commit d060edb698
11 changed files with 785 additions and 10 deletions

305
docs/通知.md Normal file
View File

@@ -0,0 +1,305 @@
# 通知接口
服务地址:`https://cmp-api.boss160.cn`
认证方式:`Bearer <JWT>`
## 通知枚举
### 通知类别
| 值 | 说明 |
| ---------- | --- |
| `approval` | 审批 |
| `expiry` | 临期 |
| `sync` | 同步 |
| `system` | 系统 |
### 通知级别
| 值 | 说明 |
| ---------- | --- |
| `info` | 提示 |
| `warning` | 警告 |
| `error` | 错误 |
| `critical` | 严重 |
## 查询通知未读数
### GET /api/admin/notifications/unread-count
查询当前认证后台账号的未过期未读通知数量,超过 99 条时显示 `99+`
#### 成功响应
```json
{
"code": 0,
"data": {
"count": 120,
"display_count": "99+"
},
"msg": "success",
"timestamp": "2026-07-25T00:00:00Z"
}
```
| 字段 | 类型 | 说明 |
| ------------------ | ------- | --------------------- |
| data.count | integer | 未读通知数量 |
| data.display_count | string | 徽标显示文本,超过 99 时为 `99+` |
## 查询通知未读分类汇总
### GET /api/admin/notifications/unread-summary
查询当前账号未过期通知的总未读数及分类数量。
#### 成功响应
```json
{
"code": 0,
"data": {
"approval": 2,
"expiry": 3,
"sync": 1,
"system": 0,
"total": 6
},
"msg": "success",
"timestamp": "2026-07-25T00:00:00Z"
}
```
| 字段 | 类型 | 说明 |
| ------------- | ------- | ------- |
| data.approval | integer | 审批类未读数量 |
| data.expiry | integer | 临期类未读数量 |
| data.sync | integer | 同步类未读数量 |
| data.system | integer | 系统类未读数量 |
| data.total | integer | 未读通知总数 |
## 查询通知列表
### GET /api/admin/notifications
查询当前认证后台账号的未过期通知,按创建时间和通知 ID 倒序返回。
#### 请求参数
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------- | --- | ---------------------------------------- |
| category | string | 否 | 通知类别:`approval``expiry``sync``system` |
| type | string | 否 | 稳定通知类型 |
| severity | string | 否 | 通知级别:`info``warning``error``critical` |
| is_read | boolean | 否 | 已读状态;不传查询全部 |
| page | integer | 否 | 页码,默认 1范围 110000 |
| page_size | integer | 否 | 每页数量,默认 20范围 150 |
#### 成功响应
```json
{
"code": 0,
"data": {
"items": [
{
"id": 1,
"category": "approval",
"type": "refund_approval_pending",
"severity": "warning",
"title": "退款审批待处理",
"body": "退款单 REFUND202607250001 正在审批中",
"is_read": false,
"read_at": null,
"ref_type": "refund",
"ref_id": "100",
"ref_key": "REFUND202607250001",
"created_at": "2026-07-25T00:00:00Z"
}
],
"page": 1,
"size": 20,
"total": 1
},
"msg": "success",
"timestamp": "2026-07-25T00:00:00Z"
}
```
#### 返回字段
| 字段 | 类型 | 说明 |
| ------------------ | ----------- | ---------------- |
| data.items | array | 通知列表 |
| data.page | integer | 当前页码 |
| data.size | integer | 每页数量 |
| data.total | integer | 总数量 |
| items[].id | integer | 通知 ID |
| items[].category | string | 通知类别 |
| items[].type | string | 稳定通知类型 |
| items[].severity | string | 通知级别 |
| items[].title | string | 纯文本标题 |
| items[].body | string | 纯文本正文 |
| items[].is_read | boolean | 是否已读 |
| items[].read_at | string/null | 首次已读时间 |
| items[].ref_type | string | 受控资源类型,不是前端路由 |
| items[].ref_id | string | 受控资源数字 ID 字符串 |
| items[].ref_key | string | 受控资源稳定 Key 或展示快照 |
| items[].created_at | string | 创建时间 |
## 标记单条通知已读
### PUT /api/admin/notifications/{id}/read
标记当前账号的一条通知为已读。通知不存在、属于其他账号或已经已读时均幂等成功。
#### 路径参数
| 参数 | 类型 | 必填 | 说明 |
| --- | ------- | --- | ------------ |
| id | integer | 是 | 通知 ID最小值为 0 |
#### 请求体
| 字段 | 类型 | 必填 | 说明 |
| --- | ------- | --- | --------------- |
| id | integer | 是 | 通知 ID必须与路径参数一致 |
请求示例:
```json
{
"id": 1
}
```
#### 成功响应
```json
{
"code": 0,
"data": {
"success": true
},
"msg": "success",
"timestamp": "2026-07-25T00:00:00Z"
}
```
## 批量标记通知已读
### PUT /api/admin/notifications/read-all
批量标记当前账号通知为已读。未传类别时更新全部未过期未读通知,传类别时只更新对应类别。
#### 请求体
| 字段 | 类型 | 必填 | 说明 |
| -------- | ------ | --- | ---------------------------------------- |
| category | string | 否 | 通知类别:`approval``expiry``sync``system` |
请求示例:
```json
{
"category": "approval"
}
```
#### 成功响应
```json
{
"code": 0,
"data": {
"updated_count": 2
},
"msg": "success",
"timestamp": "2026-07-25T00:00:00Z"
}
```
| 字段 | 类型 | 说明 |
| ------------------ | ------- | ----------- |
| data.updated_count | integer | 本次实际更新的通知数量 |
## 解析通知受控目标
### GET /api/admin/notifications/{id}/target
校验通知属于当前账号后,复核目标资源当前权限,返回白名单结构化目标。不会返回任意 URL。
#### 路径参数
| 参数 | 类型 | 必填 | 说明 |
| --- | ------- | --- | ------------ |
| id | integer | 是 | 通知 ID最小值为 0 |
#### 成功响应
```json
{
"code": 0,
"data": {
"available": true,
"target_type": "refund_detail",
"target_id": 100,
"target_key": "REFUND202607250001"
},
"msg": "success",
"timestamp": "2026-07-25T00:00:00Z"
}
```
| 字段 | 类型 | 说明 |
| ---------------- | ------------ | ------------------ |
| data.available | boolean | 当前账号是否仍可访问目标 |
| data.target_type | string | 前端白名单目标类型,空表示不支持跳转 |
| data.target_id | integer/null | ID 型目标业务主键 |
| data.target_key | string | Key 型目标稳定定位值 |
### target_type 白名单
| 值 | 说明 |
| ----------------------- | ------- |
| `refund_detail` | 退款详情 |
| `agent_recharge_detail` | 代理充值详情 |
| `wecom_approval_detail` | 企微审批详情 |
| `iot_card_detail` | IoT 卡详情 |
| `device_detail` | 设备详情 |
| `expiring_asset_list` | 临期资产列表 |
| `shop_fund_summary` | 店铺资金概况 |
| `system_config` | 系统配置 |
## 业务规则
- 通知只返回当前认证后台账号的未过期通知。
- `ref_type``ref_id``ref_key` 是受控资源引用,不是前端 URL。
- 点击通知后必须调用目标解析接口,由 `target_type``available` 决定是否跳转。
- `available=false``target_type` 为空时,只展示通知正文,不执行跳转。
- 前端维护 `target_type` 到页面的白名单映射,不得根据通知字段拼接任意 URL。
- 已读操作必须支持幂等调用。
## 错误响应
适用于以上接口:
| HTTP 状态码 | 说明 |
| -------- | --------- |
| 400 | 请求参数错误 |
| 401 | 未认证或认证已过期 |
| 403 | 无权访问 |
| 500 | 服务器内部错误 |
错误响应示例:
```json
{
"code": 1001,
"data": {},
"msg": "参数验证失败",
"timestamp": "2026-07-25T00:00:00Z"
}
```

View File

@@ -0,0 +1,29 @@
## Context
The notification API returns the response payload through the existing request wrapper, so frontend API methods should expose the typed `data` payload directly. The home page is shared by agent and enterprise accounts and is loaded after login through `/pages/agent-system/home/index`.
## Goals / Non-Goals
- Goals: expose notifications, show the newest unread message after login, and keep server read state aligned with what the user has actually seen.
- Non-Goals: notification target resolution, controlled-resource routing, push notifications, or a separate manual read button.
## Decisions
- Decision: add a dedicated notification page for the notification entry. The entry is placed in the home page's top user-information card, and the page renders notification content only; notification rows have no click navigation.
- Decision: the login page writes a short-lived pending-reminder marker after storing a valid session. The home page consumes the marker after loading the authenticated user and requests the newest unread notification.
- Decision: use `GET /api/admin/notifications/unread-count` for the entry badge and `GET /api/admin/notifications?is_read=false&page=1&page_size=1` for the login reminder. The notification page requests the newest notification page with `page_size=50`.
- Decision: call `PUT /api/admin/notifications/{id}/read` as soon as an unread notification becomes visible in the reminder or notification list, update local state optimistically, and treat the API as idempotent.
- Decision: if the unread-count or reminder request fails, keep the home page usable and do not show a fabricated notification. The normal request interceptor continues to handle authentication failures.
## Risks / Trade-offs
- Marking on render means a message can become read before the user finishes reading it; this follows the requested “seen means read” behavior.
- A notification page displays the first 50 newest records; additional pagination is outside this change unless the API data requires it.
## Migration Plan
No data migration is needed. Deploy the frontend after the notification endpoints and icon asset are available. Rolling back removes the entry and UI without changing notification records.
## Open Questions
- None for the stated behavior. The entry opens the notification page, while notification content itself never navigates to a controlled target.

View File

@@ -0,0 +1,20 @@
# Change: Add notification entry and unread reminder
## Why
The backend notification contract is documented in `docs/通知.md`, but the frontend does not expose notifications to authenticated agent or enterprise users. Users also have no immediate way to notice newly issued unread messages after signing in.
## What Changes
- Add a notification shortcut to the home page using `src/static/icons/通知.png`.
- Add a notification page that lists the current account's notifications in newest-first order.
- Show the newest unread notification in a reminder modal after a successful login redirects to the home page.
- Mark a notification as read when it is rendered as visible to the user; no manual “mark as read” action is required.
- Do not resolve `target` data and do not navigate from a notification to any controlled resource.
- Display the unread count on the home-page notification entry when it is available.
## Impact
- Affected specs: `notifications` (new capability)
- Affected code: `src/api/notifications.ts`, `src/pages/agent-system/home/index.vue`, `src/pages/agent-system/notifications/index.vue`, `src/pages.json`, and the login-to-home session flow
- Existing user-supplied assets remain in place: `docs/通知.md` and `src/static/icons/通知.png`

View File

@@ -0,0 +1,91 @@
## ADDED Requirements
### Requirement: Home notification entry
The authenticated home page SHALL expose a notification entry for both supported account types. The entry MUST use `src/static/icons/通知.png`, display the current unread count when available, and open the notification page. Notification content MUST NOT resolve controlled targets or navigate to business resources.
#### Scenario: Agent sees notification entry
- **GIVEN** an authenticated agent is viewing the home page
- **WHEN** the unread-count request succeeds
- **THEN** the home page shows the notification icon and the returned unread badge text
- **AND** activating the entry opens the notification page without resolving a target
#### Scenario: Enterprise sees notification entry
- **GIVEN** an authenticated enterprise user is viewing the home page
- **WHEN** the home page renders its shortcuts
- **THEN** the notification entry is available with the same icon and behavior
#### Scenario: Unread-count failure does not block home
- **GIVEN** the authenticated home page has loaded
- **WHEN** the unread-count request fails
- **THEN** the home page remains usable
- **AND** no fabricated unread count is displayed
### Requirement: Notification list display
The notification page SHALL request the current account's non-expired notifications from `GET /api/admin/notifications`, display them newest first using the server order, and show each notification's title, body, category, severity, and creation time. Notification rows MUST be display-only and MUST NOT invoke `GET /api/admin/notifications/{id}/target` or navigate to another business page.
#### Scenario: Latest notifications are displayed
- **GIVEN** the notification page is opened
- **WHEN** the notification list request succeeds
- **THEN** the newest notification is rendered first
- **AND** each rendered notification shows its text content and read state
#### Scenario: No notifications
- **GIVEN** the notification page is opened
- **WHEN** the server returns an empty item list
- **THEN** the page shows an empty state
- **AND** it does not attempt target resolution
### Requirement: Post-login unread reminder
After a successful login redirects to the home page, the system SHALL request the newest unread notification and show it in a reminder modal when one exists. The newest unread notification MUST be the default displayed message. The reminder MUST not navigate to a controlled target.
#### Scenario: Newest unread notification is shown after login
- **GIVEN** a user has successfully logged in and is redirected to the home page
- **AND** at least one unread notification exists
- **WHEN** the home page finishes loading the authenticated session
- **THEN** a reminder modal displays the newest unread notification's title and body by default
#### Scenario: No unread notification
- **GIVEN** a user has successfully logged in and is redirected to the home page
- **WHEN** the unread notification query returns no items
- **THEN** no reminder modal is shown
#### Scenario: Reminder query failure
- **GIVEN** a user has successfully logged in and is redirected to the home page
- **WHEN** the newest unread notification query fails
- **THEN** the home page remains usable
- **AND** no reminder modal is shown
### Requirement: Read on visibility
The system SHALL mark an unread notification as read when its content becomes visible in the reminder modal or notification list. The UI MUST NOT require a manual read button, and it MUST update the local read state after initiating the read request.
#### Scenario: Reminder marks visible notification read
- **GIVEN** the newest unread notification is shown in the post-login reminder modal
- **WHEN** the modal becomes visible
- **THEN** the client calls `PUT /api/admin/notifications/{id}/read` for that notification
- **AND** the notification is treated as read locally without a manual action
#### Scenario: List marks visible unread notifications read
- **GIVEN** the notification page renders one or more unread notifications
- **WHEN** those notification rows become visible
- **THEN** the client initiates an idempotent read request for each visible unread notification
- **AND** no manual read control is rendered
#### Scenario: Read request is idempotent
- **GIVEN** a notification has already been marked read
- **WHEN** the client repeats the read request
- **THEN** the UI remains in the read state and the repeated request does not create a user-visible error

View File

@@ -0,0 +1,18 @@
## 1. API and session flow
- [x] 1.1 Add typed notification models and list, unread-count, and single-read API methods from `docs/通知.md`.
- [x] 1.2 Set a pending notification-reminder marker after a valid login session is stored.
## 2. Notification UI
- [x] 2.1 Add the notification page and register it in `src/pages.json`.
- [x] 2.2 Add the notification shortcut and unread badge to the top user-information card using `src/static/icons/通知.png`.
- [x] 2.3 Render notification content without target resolution or controlled-resource navigation.
- [x] 2.4 Show the newest unread notification in a post-login home-page reminder modal.
- [x] 2.5 Mark each notification read when it becomes visible and remove the manual read action.
## 3. Verification
- [x] 3.1 Run `pnpm type-check`.
- [x] 3.2 Run the relevant ESLint and style checks.
- [x] 3.3 Verify the login-to-home reminder, newest-first display, entry badge, and read-on-visible behavior.

72
src/api/notifications.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* 通知模块 API
*/
import { get, put } from '@/utils/request';
export type NotificationCategory = 'approval' | 'expiry' | 'sync' | 'system';
export type NotificationSeverity = 'info' | 'warning' | 'error' | 'critical';
/**
* 通知记录
*/
export interface NotificationItem {
id: number;
category: NotificationCategory;
type: string;
severity: NotificationSeverity;
title: string;
body: string;
is_read: boolean;
read_at: string | null;
ref_type?: string | null;
ref_id?: string | null;
ref_key?: string | null;
created_at: string;
}
export interface NotificationListParams {
category?: NotificationCategory;
type?: string;
severity?: NotificationSeverity;
is_read?: boolean;
page?: number;
page_size?: number;
}
export interface NotificationListResponse {
items: NotificationItem[];
page: number;
size: number;
total: number;
}
export interface NotificationUnreadCountResponse {
count: number;
display_count: string;
}
/**
* 查询通知列表
* GET /api/admin/notifications
*/
export function getNotifications(params?: NotificationListParams) {
return get<NotificationListResponse>('/api/admin/notifications', { params });
}
/**
* 查询通知未读数
* GET /api/admin/notifications/unread-count
*/
export function getNotificationUnreadCount() {
return get<NotificationUnreadCountResponse>('/api/admin/notifications/unread-count');
}
/**
* 标记单条通知已读
* PUT /api/admin/notifications/{id}/read
*/
export function markNotificationRead(id: number) {
return put<{ success: boolean }>(`/api/admin/notifications/${id}/read`, {
data: { id },
});
}

View File

@@ -15,6 +15,14 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/agent-system/notifications/index",
"style": {
"navigationBarTitleText": "通知",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/assets/index",
"style": {

View File

@@ -1,12 +1,14 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, nextTick, onMounted } from 'vue'
import { getUserInfo, logout as logoutApi } from '@/api/auth'
import { clearToken } from '@/utils/auth'
import type { UserInfo } from '@/api/auth'
import { getFundSummary, getCommissionDailyStats } from '@/api/commission'
import type { FundSummary, DailyCommissionStats } from '@/api/commission'
import { getNotificationUnreadCount, getNotifications, markNotificationRead } from '@/api/notifications'
import AgentFundSummaryCard from '@/components/AgentFundSummaryCard.vue'
const NOTIFICATION_REMINDER_PENDING_KEY = 'notification_reminder_pending'
// 用户信息
const userInfo = ref<UserInfo | null>(null)
@@ -20,10 +22,16 @@
// 店铺ID
const shopId = ref<number>(0)
// 通知未读数
const notificationUnreadCount = ref<number | null>(null)
// 是否为代理账号
const isAgent = computed(() => userInfo.value?.user_type === 3)
const todayStat = computed(() => dailyStats.value[0] || null)
const notificationUnreadDisplayCount = computed(() => {
if (!notificationUnreadCount.value) return ''
return notificationUnreadCount.value > 99 ? '99+' : String(notificationUnreadCount.value)
})
function getLoginUserInfo() {
return uni.getStorageSync('user_info') as Partial<UserInfo> | undefined
@@ -113,6 +121,62 @@
}
}
async function loadNotificationUnreadCount() {
try {
const response = await getNotificationUnreadCount()
notificationUnreadCount.value = Math.max(response.count || 0, 0)
}
catch (error) {
console.error('加载通知未读数失败:', error)
}
}
function decreaseNotificationUnreadCount() {
if (notificationUnreadCount.value && notificationUnreadCount.value > 0) {
notificationUnreadCount.value -= 1
}
}
async function showUnreadNotificationReminder() {
if (uni.getStorageSync(NOTIFICATION_REMINDER_PENDING_KEY) !== true) return
// 一次登录只消费一次提醒标记,避免首页重复展示同一条提醒。
uni.removeStorageSync(NOTIFICATION_REMINDER_PENDING_KEY)
try {
const response = await getNotifications({
is_read: false,
page: 1,
page_size: 1,
})
const notification = response.items?.[0]
if (!notification) return
uni.showModal({
title: notification.title || '通知提醒',
content: notification.body || '你有一条新的未读通知',
showCancel: false,
confirmText: '知道了',
})
// 弹窗已经把通知内容呈现给用户,立即同步已读状态,不要求额外点击“已读”。
decreaseNotificationUnreadCount()
void markNotificationRead(notification.id).catch((error) => {
console.error(`标记通知 ${notification.id} 已读失败:`, error)
})
}
catch (error) {
console.error('加载未读通知提醒失败:', error)
}
}
async function loadNotificationData() {
await Promise.all([
loadNotificationUnreadCount(),
showUnreadNotificationReminder(),
])
}
// 跳转页面
function navigateTo(path : string) {
uni.navigateTo({ url: path })
@@ -137,6 +201,7 @@
clearToken()
uni.removeStorageSync('refresh_token')
uni.removeStorageSync('user_info')
uni.removeStorageSync(NOTIFICATION_REMINDER_PENDING_KEY)
// 跳转到登录页
uni.reLaunch({
@@ -148,8 +213,12 @@
})
}
onMounted(() => {
loadHomeData()
onMounted(async () => {
await loadHomeData()
if (userInfo.value) {
await nextTick()
await loadNotificationData()
}
})
</script>
@@ -175,17 +244,25 @@
</view>
<!-- 右侧用户信息 -->
<view class="flex-1">
<text class="text-18px font-700 text-[#212121] block mb-1">
{{ userInfo?.username || '欢迎回来' }}
</text>
<text class="text-13px text-[#999]">
{{ userInfo?.phone || '-' }}
</text>
<view class="flex-1">
<text class="text-18px font-700 text-[#212121] block mb-1">
{{ userInfo?.username || '欢迎回来' }}
</text>
<text class="text-13px text-[#999]">
{{ userInfo?.phone || '-' }}
</text>
</view>
<!-- 通知入口 -->
<view class="relative w-44px h-44px rounded-14px bg-[#fff7ed] flex items-center justify-center ml-3" @click="navigateTo('/pages/agent-system/notifications/index')">
<image src="@/static/icons/通知.png" class="w-26px h-26px" mode="aspectFit" />
<view v-if="notificationUnreadDisplayCount" class="absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full bg-[#ff4d4f] flex items-center justify-center">
<text class="text-10px text-white leading-none">{{ notificationUnreadDisplayCount }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 代理端: 佣金展示 -->
<view v-if="isAgent" class="px-4 pt-4">

View File

@@ -0,0 +1,153 @@
<template>
<view class="min-h-screen bg-[#f7f8fa] px-4 py-4">
<view v-if="loading" class="flex justify-center pt-20">
<text class="text-14px text-[#64748b]">
加载中...
</text>
</view>
<view v-else-if="notifications.length" class="space-y-3">
<view
v-for="notification in notifications"
:key="notification.id"
class="rounded-16px bg-white p-4 shadow-[0_2px_10px_rgba(0,0,0,0.04)]"
>
<view class="flex items-start justify-between gap-3">
<view class="min-w-0 flex flex-1 items-center gap-2">
<text class="truncate text-15px text-[#212121] font-600">
{{ notification.title }}
</text>
<view
v-if="!notification.is_read"
class="flex-shrink-0 rounded-4px bg-[#fff1f0] px-1.5 py-0.5 text-10px text-[#f5222d]"
>
未读
</view>
</view>
<text class="flex-shrink-0 text-11px text-[#999]">
{{ formatTime(notification.created_at) }}
</text>
</view>
<text class="mt-3 break-all text-13px text-[#666] leading-6">
{{ notification.body }}
</text>
<view class="mt-3 flex items-center gap-2">
<view class="rounded-4px bg-[#f5f5f5] px-2 py-0.5 text-11px text-[#666]">
{{ categoryLabels[notification.category] || notification.category }}
</view>
<view
class="rounded-4px px-2 py-0.5 text-11px"
:class="getSeverityClass(notification.severity)"
>
{{ severityLabels[notification.severity] || notification.severity }}
</view>
<text class="ml-auto text-11px text-[#b5b5b5]">
{{ notification.is_read ? '已读' : '未读' }}
</text>
</view>
</view>
</view>
<view v-else class="flex flex-col items-center pt-24">
<image src="@/static/icons/通知.png" class="h-18 w-18 opacity-40" mode="aspectFit" />
<text class="mt-4 text-14px text-[#999]">
暂无通知
</text>
</view>
</view>
</template>
<script setup lang="ts">
import type {
NotificationCategory,
NotificationItem,
NotificationSeverity,
} from '@/api/notifications';
import { nextTick, onMounted, ref } from 'vue';
import {
getNotifications,
markNotificationRead,
} from '@/api/notifications';
const notifications = ref<NotificationItem[]>([]);
const loading = ref(true);
const categoryLabels: Record<NotificationCategory, string> = {
approval: '审批',
expiry: '临期',
sync: '同步',
system: '系统',
};
const severityLabels: Record<NotificationSeverity, string> = {
info: '提示',
warning: '警告',
error: '错误',
critical: '严重',
};
function formatTime(time: string) {
if (!time) return '-';
const date = new Date(time);
if (Number.isNaN(date.getTime())) return time;
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function getSeverityClass(severity: NotificationSeverity) {
const classes: Record<NotificationSeverity, string> = {
info: 'bg-[#e6f4ff] text-[#1677ff]',
warning: 'bg-[#fff7e6] text-[#d46b08]',
error: 'bg-[#fff2f0] text-[#cf1322]',
critical: 'bg-[#fff1f0] text-[#a8071a]',
};
return classes[severity];
}
async function markVisibleNotificationsAsRead() {
await nextTick();
const unreadNotifications = notifications.value.filter(notification => !notification.is_read);
unreadNotifications.forEach((notification) => {
notification.is_read = true;
notification.read_at = new Date().toISOString();
void markNotificationRead(notification.id).catch((error) => {
console.error(`标记通知 ${notification.id} 已读失败:`, error);
});
});
}
async function loadNotifications() {
loading.value = true;
try {
const response = await getNotifications({
page: 1,
page_size: 50,
});
notifications.value = response.items || [];
loading.value = false;
await markVisibleNotificationsAsRead();
}
catch (error) {
console.error('加载通知失败:', error);
}
finally {
loading.value = false;
}
}
onMounted(() => {
loadNotifications();
});
</script>
<style scoped>
.space-y-3 > view:not(:last-child) {
margin-bottom: 12px;
}
</style>

View File

@@ -74,6 +74,7 @@ const accountFocused = ref<boolean>(false);
const passwordFocused = ref<boolean>(false);
const showPassword = ref<boolean>(false);
const loading = ref<boolean>(false);
const NOTIFICATION_REMINDER_PENDING_KEY = 'notification_reminder_pending';
let redirect = HOME_PATH;
const isFormValid = computed(() => {
@@ -139,6 +140,7 @@ async function submit() {
// 保存用户信息到本地存储
uni.setStorageSync('user_info', sessionUser);
uni.setStorageSync(NOTIFICATION_REMINDER_PENDING_KEY, true);
uni.showToast({
title: '登录成功',

BIN
src/static/icons/通知.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB