From d060edb69833bebde76a5da334f266bfee0e905d Mon Sep 17 00:00:00 2001 From: luo Date: Wed, 29 Jul 2026 10:25:13 +0800 Subject: [PATCH] feat: add notification feature --- docs/通知.md | 305 ++++++++++++++++++ .../design.md | 29 ++ .../proposal.md | 20 ++ .../specs/notifications/spec.md | 91 ++++++ .../tasks.md | 18 ++ src/api/notifications.ts | 72 +++++ src/pages.json | 8 + src/pages/agent-system/home/index.vue | 97 +++++- .../agent-system/notifications/index.vue | 153 +++++++++ src/pages/common/login/index.vue | 2 + src/static/icons/通知.png | Bin 0 -> 6267 bytes 11 files changed, 785 insertions(+), 10 deletions(-) create mode 100644 docs/通知.md create mode 100644 openspec/changes/add-notification-entry-and-unread-reminder/design.md create mode 100644 openspec/changes/add-notification-entry-and-unread-reminder/proposal.md create mode 100644 openspec/changes/add-notification-entry-and-unread-reminder/specs/notifications/spec.md create mode 100644 openspec/changes/add-notification-entry-and-unread-reminder/tasks.md create mode 100644 src/api/notifications.ts create mode 100644 src/pages/agent-system/notifications/index.vue create mode 100644 src/static/icons/通知.png diff --git a/docs/通知.md b/docs/通知.md new file mode 100644 index 0000000..05a5270 --- /dev/null +++ b/docs/通知.md @@ -0,0 +1,305 @@ +# 通知接口 + +服务地址:`https://cmp-api.boss160.cn` + +认证方式:`Bearer ` + +## 通知枚举 + +### 通知类别 + +| 值 | 说明 | +| ---------- | --- | +| `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" +} +``` diff --git a/openspec/changes/add-notification-entry-and-unread-reminder/design.md b/openspec/changes/add-notification-entry-and-unread-reminder/design.md new file mode 100644 index 0000000..bb6b40d --- /dev/null +++ b/openspec/changes/add-notification-entry-and-unread-reminder/design.md @@ -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. diff --git a/openspec/changes/add-notification-entry-and-unread-reminder/proposal.md b/openspec/changes/add-notification-entry-and-unread-reminder/proposal.md new file mode 100644 index 0000000..294fba1 --- /dev/null +++ b/openspec/changes/add-notification-entry-and-unread-reminder/proposal.md @@ -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` diff --git a/openspec/changes/add-notification-entry-and-unread-reminder/specs/notifications/spec.md b/openspec/changes/add-notification-entry-and-unread-reminder/specs/notifications/spec.md new file mode 100644 index 0000000..7cfee9f --- /dev/null +++ b/openspec/changes/add-notification-entry-and-unread-reminder/specs/notifications/spec.md @@ -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 diff --git a/openspec/changes/add-notification-entry-and-unread-reminder/tasks.md b/openspec/changes/add-notification-entry-and-unread-reminder/tasks.md new file mode 100644 index 0000000..58dcf7b --- /dev/null +++ b/openspec/changes/add-notification-entry-and-unread-reminder/tasks.md @@ -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. diff --git a/src/api/notifications.ts b/src/api/notifications.ts new file mode 100644 index 0000000..79a86a6 --- /dev/null +++ b/src/api/notifications.ts @@ -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('/api/admin/notifications', { params }); +} + +/** + * 查询通知未读数 + * GET /api/admin/notifications/unread-count + */ +export function getNotificationUnreadCount() { + return get('/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 }, + }); +} diff --git a/src/pages.json b/src/pages.json index 0dcf042..0312ca1 100644 --- a/src/pages.json +++ b/src/pages.json @@ -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": { diff --git a/src/pages/agent-system/home/index.vue b/src/pages/agent-system/home/index.vue index 27237f0..845bd29 100644 --- a/src/pages/agent-system/home/index.vue +++ b/src/pages/agent-system/home/index.vue @@ -1,12 +1,14 @@ @@ -175,17 +244,25 @@ - - - {{ userInfo?.username || '欢迎回来' }} - - - {{ userInfo?.phone || '-' }} - + + + {{ userInfo?.username || '欢迎回来' }} + + + {{ userInfo?.phone || '-' }} + + + + + + + + {{ notificationUnreadDisplayCount }} + diff --git a/src/pages/agent-system/notifications/index.vue b/src/pages/agent-system/notifications/index.vue new file mode 100644 index 0000000..08bfca0 --- /dev/null +++ b/src/pages/agent-system/notifications/index.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/src/pages/common/login/index.vue b/src/pages/common/login/index.vue index b1460b7..3837a8b 100644 --- a/src/pages/common/login/index.vue +++ b/src/pages/common/login/index.vue @@ -74,6 +74,7 @@ const accountFocused = ref(false); const passwordFocused = ref(false); const showPassword = ref(false); const loading = ref(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: '登录成功', diff --git a/src/static/icons/通知.png b/src/static/icons/通知.png new file mode 100644 index 0000000000000000000000000000000000000000..8315cdba5cc7579f5641e70b924a35106411fce8 GIT binary patch literal 6267 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 A(f|Me literal 0 HcmV?d00001