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 0000000..8315cdb Binary files /dev/null and b/src/static/icons/通知.png differ