feat: add notification feature
This commit is contained in:
305
docs/通知.md
Normal file
305
docs/通知.md
Normal 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,范围 1~10000 |
|
||||
| page_size | integer | 否 | 每页数量,默认 20,范围 1~50 |
|
||||
|
||||
#### 成功响应
|
||||
|
||||
```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"
|
||||
}
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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
72
src/api/notifications.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
153
src/pages/agent-system/notifications/index.vue
Normal file
153
src/pages/agent-system/notifications/index.vue
Normal 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>
|
||||
@@ -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
BIN
src/static/icons/通知.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Reference in New Issue
Block a user