feat: 完善接口19-顶部通知铃铛与站内通知中心
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m5s
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m5s
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
## Context
|
||||
|
||||
临期结果由后端根据资产当前套餐及排队套餐计算。管理端需要在独立列表、普通资产列表、代理首页和通知中心保持相同的临期语义。本变更只覆盖管理端,C 端资产和续费能力不纳入实现范围。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
- Goals: 统一消费 `estimated_final_expires_at`、`days_until_final_expiry`、`expiry_level`、`expiry_level_name` 和 `can_renew`;提供临期查询、排序、高亮、统计、通知跳转和管理端续费入口。
|
||||
- Goals: 由后端负责临期边界、预计到期结果和通知触发,前端只负责展示和路由。
|
||||
- Non-Goals: 不计算套餐接续到期时间,不修改历史订单或已购套餐,不实现 C 端入口,不展示企微临期消息。
|
||||
|
||||
## Decisions
|
||||
|
||||
- Decision: 临期独立列表使用 `GET /api/admin/expiring-assets`,查询参数保持文档约定的 `asset_type`、`keyword`、`shop_id`、`package_id`、`days_min`、`days_max`、`expires_from`、`expires_to`、`page`、`size`。
|
||||
- Decision: 临期接口负责跨分页排序,返回结果按 0-3 天优先、其余按 `estimated_final_expires_at` 升序排列;前端只保留接口顺序,不改变普通资产列表原始接口排序。
|
||||
- Decision: 颜色只按剩余天数映射:8-15 天粉红、4-7 天紫色、0-3 天红色。已过期和不可预计记录由接口排除,前端不把它们补入临期列表。
|
||||
- Decision: 续费入口由 `can_renew` 控制,具体续费动作复用现有管理端续费/充值路由和接口,不新增 C 端逻辑。
|
||||
- Decision: 通知中心继续复用现有通知接口和目标跳转协议;临期通知由后端按 15 天、7 天、3 天生成,前端按通知目标跳转到临期列表或对应资产详情。
|
||||
- Alternatives considered: 前端从普通资产列表筛选临期记录。Rejected because it无法保证全量、统一排序和后端预计最终到期结果的一致性。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- 风险:代理首页当前概览接口未必已有临期计数字段。Mitigation:在现有首页/概览响应中增加 `expiring_card_count` 和 `expiring_device_count`,避免前端分页统计。
|
||||
- 风险:不同资产列表的字段结构不完全一致。Mitigation:在各自 API 类型中复用同一组临期字段语义,并通过统一展示格式处理空值和等级。
|
||||
- 风险:续费入口的现有路由可能依赖资产类型。Mitigation:由 `asset_type` 和 `asset_id` 选择对应管理端续费入口,按钮仅在 `can_renew=true` 时展示。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 先扩展管理端 API 类型和服务,确认列表、首页统计和通知目标字段。
|
||||
2. 上线临期列表及普通列表高亮,再接入首页统计和续费入口。
|
||||
3. 最后验证通知中心过滤、已读和跳转,不修改 C 端功能。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 代理首页现有概览接口的具体路径和响应字段名称,需要以后端接口实现为准确认。
|
||||
- 管理端卡/设备续费入口的现有路由是否统一,需实现时复用当前权限和路由定义。
|
||||
@@ -0,0 +1,35 @@
|
||||
# Change: 增加管理端临期资产高亮、通知与续费入口
|
||||
|
||||
## Why
|
||||
|
||||
管理端目前缺少统一的临期资产查询入口,运营人员需要在普通资产列表中自行识别临近到期资产,且无法从临期记录快速进入续费操作。需要基于后端统一计算的预计最终到期结果,在管理端集中展示临期资产、标记普通列表并接入站内通知。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增管理端临期资产列表,支持资产类型、关键字、店铺、套餐、剩余天数和预计到期时间筛选。
|
||||
- 临期列表按 0-3 天固定置顶,其余按预计最终到期时间升序;已过期和不可预计资产不进入列表。
|
||||
- 在卡列表、设备列表和资产相关列表中增加临期颜色高亮,但不改变普通列表原有排序。
|
||||
- 在代理首页增加临期卡数量和临期设备数量展示,并可跳转临期列表。
|
||||
- 在临期列表提供后端返回 `can_renew` 控制的续费入口,复用现有管理端续费/充值流程。
|
||||
- 接入通知中心中的 15 天、7 天、3 天临期提醒及通知跳转;管理端不展示企微临期消息。
|
||||
- 所有页面直接使用后端返回的预计最终到期字段和临期等级,不在前端重新计算最终到期时间。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs:
|
||||
- `admin-expiring-assets`
|
||||
- Affected code:
|
||||
- `src/api/modules` 和 `src/types/api` 中的临期资产接口及类型
|
||||
- 管理端临期资产列表页面和路由
|
||||
- 网卡列表、设备列表及资产信息展示组件
|
||||
- 代理首页统计组件
|
||||
- 通知中心跳转处理
|
||||
- API contracts:
|
||||
- `GET /api/admin/expiring-assets`
|
||||
- 现有代理首页/概览接口增加临期卡、设备数量字段,或提供等价的管理端统计响应
|
||||
- 复用现有通知查询、已读和目标跳转接口
|
||||
- Out of scope:
|
||||
- C 端资产页临期展示
|
||||
- C 端续费按钮和 C 端续费流程
|
||||
- 企微临期消息展示或企微通知改造
|
||||
- 前端根据套餐明细自行推导预计最终到期时间
|
||||
@@ -0,0 +1,111 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Admin Expiring Asset Query
|
||||
|
||||
The admin frontend SHALL provide an expiring asset list backed by `GET /api/admin/expiring-assets`. The query SHALL support `asset_type`, `keyword`, `shop_id`, `package_id`, `days_min`, `days_max`, `expires_from`, `expires_to`, `page`, and `size`.
|
||||
|
||||
#### Scenario: Filter expiring assets
|
||||
|
||||
- **WHEN** 管理端用户打开临期资产列表并提交筛选条件
|
||||
- **THEN** 前端 MUST send the documented query parameters to `GET /api/admin/expiring-assets`
|
||||
- **AND** 页面 MUST display the paginated response items
|
||||
|
||||
#### Scenario: Exclude unavailable assets
|
||||
|
||||
- **WHEN** 接口返回临期列表
|
||||
- **THEN** 页面 MUST not add expired assets or assets without an estimable final expiry to the list on the client side
|
||||
|
||||
### Requirement: Expiring Asset Fields
|
||||
|
||||
Each expiring asset item SHALL preserve and display the backend fields `asset_type`, `asset_id`, `identifier`, `shop_name`, `package_name`, `estimated_final_expires_at`, `days_until_final_expiry`, `expiry_level`, `expiry_level_name`, and `can_renew`. The frontend MUST use backend-provided expiry results and MUST NOT calculate the estimated final expiry from package details.
|
||||
|
||||
#### Scenario: Display an estimable asset
|
||||
|
||||
- **WHEN** 临期接口返回资产及 `estimated_final_expires_at`
|
||||
- **THEN** 页面 MUST display the asset identifier, shop, package, estimated final expiry, remaining days, and backend expiry level name
|
||||
|
||||
#### Scenario: Preserve backend result
|
||||
|
||||
- **WHEN** 临期接口返回 `estimated_final_expires_at` 或 `days_until_final_expiry`
|
||||
- **THEN** 前端 MUST display the returned values
|
||||
- **AND** 前端 MUST NOT derive or replace them from current or queued package data
|
||||
|
||||
### Requirement: Expiring List Ordering And Highlighting
|
||||
|
||||
The expiring asset API and standalone admin list SHALL return and preserve records with 0-3 remaining days first, then order the remaining records by `estimated_final_expires_at` ascending. The list SHALL apply pink highlighting to 8-15 days, purple highlighting to 4-7 days, and red highlighting to 0-3 days.
|
||||
|
||||
#### Scenario: Order critical assets first
|
||||
|
||||
- **GIVEN** the response contains assets in multiple expiry ranges
|
||||
- **WHEN** 页面渲染临期列表
|
||||
- **THEN** the API response MUST place assets with 0-3 remaining days before assets with more remaining days
|
||||
- **AND** the API response MUST order records in the remaining ranges by estimated final expiry ascending
|
||||
- **AND** the frontend MUST preserve that cross-page ordering
|
||||
|
||||
#### Scenario: Highlight by remaining days
|
||||
|
||||
- **WHEN** 页面渲染临期资产
|
||||
- **THEN** 8-15 days MUST use the pink visual treatment
|
||||
- **AND** 4-7 days MUST use the purple visual treatment
|
||||
- **AND** 0-3 days MUST use the red visual treatment
|
||||
|
||||
### Requirement: Ordinary Admin Asset List Highlighting
|
||||
|
||||
The admin card and device lists SHALL apply the same expiry color treatment to returned expiring asset fields without changing their existing server or client sort order.
|
||||
|
||||
#### Scenario: Highlight ordinary asset rows
|
||||
|
||||
- **WHEN** 卡列表或设备列表返回临期字段
|
||||
- **THEN** 页面 MUST apply the matching expiry color treatment
|
||||
- **AND** 页面 MUST preserve the list's existing ordering
|
||||
|
||||
### Requirement: Admin Expiring Asset Renewal Entry
|
||||
|
||||
The admin expiring asset list SHALL display a renewal action only when the backend item has `can_renew=true`. The action SHALL navigate to the existing management-side renewal or recharge flow for the asset type.
|
||||
|
||||
#### Scenario: Renewable asset
|
||||
|
||||
- **GIVEN** 临期资产项返回 `can_renew=true`
|
||||
- **WHEN** 用户查看临期列表
|
||||
- **THEN** 页面 MUST provide the management-side renewal entry
|
||||
- **AND** the entry MUST target the corresponding asset and preserve asset type context
|
||||
|
||||
#### Scenario: Non-renewable asset
|
||||
|
||||
- **GIVEN** 临期资产项返回 `can_renew=false`
|
||||
- **WHEN** 用户查看临期列表
|
||||
- **THEN** 页面 MUST NOT display an enabled renewal action
|
||||
|
||||
### Requirement: Agent Dashboard Expiry Counts
|
||||
|
||||
The agent-facing admin home SHALL display the number of expiring cards and expiring devices using backend-provided summary fields and SHALL provide navigation to the standalone expiring asset list.
|
||||
|
||||
#### Scenario: Display expiry counts
|
||||
|
||||
- **WHEN** 代理首页概览数据返回临期卡和设备数量
|
||||
- **THEN** 页面 MUST display the expiring card count and expiring device count
|
||||
- **AND** 页面 MUST not calculate the counts from a paginated asset list
|
||||
|
||||
### Requirement: Admin Expiry Notifications
|
||||
|
||||
The notification center SHALL display backend-generated admin expiry reminders for 15 days, 7 days, and 3 days through the existing notification API and target navigation. The admin frontend SHALL not display WeCom expiry messages.
|
||||
|
||||
#### Scenario: Display expiry reminder
|
||||
|
||||
- **WHEN** 通知接口返回 15 天、7 天或 3 天的临期提醒
|
||||
- **THEN** 通知中心 MUST classify it as a 临期提醒
|
||||
- **AND** 用户点击通知后 MUST navigate to the returned management-side target
|
||||
|
||||
#### Scenario: Exclude WeCom expiry message
|
||||
|
||||
- **WHEN** 管理端加载通知中心
|
||||
- **THEN** 页面 MUST not add or render a separate WeCom expiry message channel
|
||||
|
||||
### Requirement: Admin-Only Scope
|
||||
|
||||
This capability SHALL be limited to the management frontend. It SHALL NOT add or modify C-end asset expiry display, C-end renewal buttons, C-end renewal APIs, or C-end notification behavior.
|
||||
|
||||
#### Scenario: Keep C-end unchanged
|
||||
|
||||
- **WHEN** this change is implemented
|
||||
- **THEN** C-end asset pages and renewal flows MUST remain outside the change scope
|
||||
@@ -0,0 +1,35 @@
|
||||
## 1. API Contract
|
||||
|
||||
- [x] 1.1 增加 `GET /api/admin/expiring-assets` 的查询参数、分页响应和临期资产项类型。
|
||||
- [x] 1.2 为卡列表、设备列表、资产信息和首页概览类型补充统一临期字段及临期计数字段。
|
||||
- [x] 1.3 增加临期资产 API 服务方法,并确认续费入口复用现有管理端服务和权限。
|
||||
|
||||
## 2. Expiring Asset List
|
||||
|
||||
- [x] 2.1 增加或接入 `/operations/expiring-assets` 管理端路由和页面。
|
||||
- [x] 2.2 实现资产类型、关键字、店铺、套餐、剩余天数和预计到期时间筛选。
|
||||
- [x] 2.3 展示资产、店铺、当前套餐、预计最终到期、剩余天数和后端临期等级名称。
|
||||
- [x] 2.4 实现 0-3 天置顶、其余按预计到期时间升序,并排除已过期和不可预计记录。
|
||||
- [x] 2.5 按 8-15 天粉红、4-7 天紫色、0-3 天红色实现行或到期字段高亮。
|
||||
- [x] 2.6 根据 `can_renew` 展示管理端续费入口,并跳转现有续费/充值流程。
|
||||
|
||||
## 3. Existing Admin Pages
|
||||
|
||||
- [x] 3.1 在网卡普通列表增加临期字段展示和颜色高亮,不改变原排序。
|
||||
- [x] 3.2 在设备普通列表增加临期字段展示和颜色高亮,不改变原排序。
|
||||
- [x] 3.3 在资产信息详情复用同一临期展示规则,禁止前端重新计算预计最终到期时间。
|
||||
- [x] 3.4 在代理首页展示临期卡数量和临期设备数量,并支持跳转临期列表。
|
||||
|
||||
## 4. Notifications
|
||||
|
||||
- [x] 4.1 在通知中心复用临期分类,展示后端生成的 15 天、7 天、3 天临期提醒。
|
||||
- [x] 4.2 使用现有通知目标跳转到临期列表或对应管理端资产详情。
|
||||
- [x] 4.3 确认管理端不展示企微临期消息,不新增 C 端通知入口。
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- [x] 5.1 验证临期字段直接使用后端值,前端不根据套餐明细推导最终到期时间。
|
||||
- [x] 5.2 验证 0-3 天置顶、日期升序、颜色映射和普通列表原排序保持不变。
|
||||
- [x] 5.3 验证 `can_renew=false` 不展示续费入口,且各资产类型使用正确管理端入口。
|
||||
- [x] 5.4 验证通知中心临期分类、已读状态和跳转行为。
|
||||
- [x] 5.5 运行类型检查、相关 ESLint/Stylelint、构建和 `openspec validate add-admin-expiring-assets-notifications --strict`。
|
||||
@@ -0,0 +1,36 @@
|
||||
## Context
|
||||
|
||||
后端通知接口已经提供稳定的管理端 REST 合约。前端需要移除对旧字段和旧响应形态的依赖,统一使用真实接口返回的数据,并把通知目标解析限制在前端已注册的内部目标映射中。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
- Goals: 顶部铃铛、通知抽屉、通知中心和共享 Pinia 状态使用同一套真实 API 类型。
|
||||
- Goals: 保证未读数、分类汇总、列表已读状态在单条已读和全部已读后与后端同步。
|
||||
- Goals: 受控目标只能通过 `target_type`、`target_id`、`target_key` 白名单映射到内部页面。
|
||||
- Non-Goals: 不实现 C 端通知,不支持任意 URL 跳转,不改变通知产生规则。
|
||||
|
||||
## Decisions
|
||||
|
||||
- Decision: 通知列表请求使用 `is_read?: boolean`,不再以 `read_status` 字符串代替后端参数。
|
||||
- Decision: 通知正文统一使用 `body`;必要时仅在迁移读取阶段兼容旧数据,不再把旧 `content` 作为 API 契约类型。
|
||||
- Decision: 顶部徽标优先使用 `unread-count.data.display_count`,数值统计使用 `data.count`;不在前端重新格式化徽标文本。
|
||||
- Decision: 未读汇总只保存四个固定分类计数和总数;抽屉最近10条通过 `GET /api/admin/notifications?page=1&page_size=10` 获取,避免把分类汇总误当通知列表。
|
||||
- Decision: 全部已读服务方法接收可选 `category`,请求体始终为对象;通知中心的“全部已读”传空对象,分类操作可传具体类别。
|
||||
- Decision: `target_type`、`target_key` 由前端白名单映射到已注册路由,`target_id` 仅作为参数;未知、无权限或已失效目标只展示正文,不跳转。
|
||||
- Alternatives considered: 继续保留旧字段的宽松兼容类型。Rejected because it会掩盖真实 API 字段错误,并导致筛选和已读状态请求不符合后端契约。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- 风险:后端新增通知类型。Mitigation:类型字段保持 string,页面对未知类型采用原值展示。
|
||||
- 风险:受控目标类型新增。Mitigation:未知目标默认不可跳转,不执行后端返回的任意路径。
|
||||
- 风险:列表与汇总请求并行时数据短暂不一致。Mitigation:单条已读/全部已读成功后刷新未读数、汇总和当前列表。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 先更新类型、API service 和通知 store 的真实响应结构。
|
||||
2. 再更新顶部抽屉和通知中心字段、筛选及已读交互。
|
||||
3. 最后替换目标跳转映射并验证未知目标安全降级。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- `target_type` 与 `target_key` 的完整白名单值需要以后端实际返回样例补充;未确认值统一按不可跳转处理。
|
||||
@@ -0,0 +1,40 @@
|
||||
# Change: 按实际接口更新管理端通知中心
|
||||
|
||||
## Why
|
||||
|
||||
通知中心已有前端链路,但当前类型和页面仍兼容旧字段,例如 `content`、`read_status`、旧的未读摘要结构和可路由目标结构。后端接口文档已更新为实际可用的管理端 API,需要按真实响应字段和受控目标协议修正通知铃铛、抽屉、通知中心及状态同步逻辑。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 按实际接口更新通知项字段:`body`、`is_read`、`read_at`、`ref_type`、`ref_id`、`ref_key`。
|
||||
- 按实际接口更新通知列表筛选参数:使用 `is_read` boolean,支持 `category`、`type`、`severity`、`page`、`page_size`。
|
||||
- 接入真实未读数响应 `{ count, display_count }`,顶部徽标直接展示后端 `display_count`。
|
||||
- 接入真实未读分类汇总 `{ approval, expiry, sync, system, total }`,抽屉最近通知通过通知列表接口获取,最多展示10条。
|
||||
- 全部已读调用 `PUT /api/admin/notifications/read-all`,支持可选 `category` 请求体。
|
||||
- 受控目标按 `target_type`、`target_id`、`target_key` 建立前端白名单映射,不接受或执行任意 URL/路由字段。
|
||||
- 通知中心展示正文 `body`、读取状态 `is_read`、首次已读时间 `read_at`,并支持余额、审批、临期、同步/系统分类。
|
||||
- 本变更只覆盖管理端,明确不接入 C 端 `/api/c/v1/notifications`。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs:
|
||||
- `notification-center`
|
||||
- Affected code:
|
||||
- `src/types/api/notification.ts`
|
||||
- `src/api/modules/notification.ts`
|
||||
- `src/store/modules/notification.ts`
|
||||
- `src/components/core/layouts/art-header-bar/index.vue`
|
||||
- `src/components/core/layouts/art-notification/index.vue`
|
||||
- `src/views/notifications/index.vue`
|
||||
- `src/utils/business/notificationNavigation.ts`
|
||||
- API contracts:
|
||||
- `GET /api/admin/notifications`
|
||||
- `PUT /api/admin/notifications/{id}/read`
|
||||
- `PUT /api/admin/notifications/read-all`
|
||||
- `GET /api/admin/notifications/unread-count`
|
||||
- `GET /api/admin/notifications/unread-summary`
|
||||
- `GET /api/admin/notifications/{id}/target`
|
||||
- Out of scope:
|
||||
- C 端 `/api/c/v1/notifications` 接口和页面
|
||||
- 创建、编辑或删除通知
|
||||
- WebSocket、浏览器推送和新的实时推送机制
|
||||
@@ -0,0 +1,84 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Admin Notification Contract
|
||||
|
||||
The admin notification frontend SHALL use the actual notification contract returned by `GET /api/admin/notifications`. Each item SHALL preserve `id`, `title`, `body`, `category`, `type`, `severity`, `is_read`, `created_at`, `read_at`, `ref_type`, `ref_id`, and `ref_key`.
|
||||
|
||||
#### Scenario: Display actual notification fields
|
||||
|
||||
- **WHEN** 管理端通知列表返回通知项
|
||||
- **THEN** 页面 MUST display `title` and `body`
|
||||
- **AND** 页面 MUST use `is_read` for read state
|
||||
- **AND** 页面 MUST preserve `read_at` and `ref_key`
|
||||
|
||||
### Requirement: Admin Notification List Filters
|
||||
|
||||
The notification center SHALL query `GET /api/admin/notifications` with `category`, `type`, `severity`, `is_read`, `page`, and `page_size`. The frontend SHALL not send `read_status` as a replacement for `is_read`.
|
||||
|
||||
#### Scenario: Filter unread notifications
|
||||
|
||||
- **WHEN** 用户选择未读筛选
|
||||
- **THEN** 前端 MUST send `is_read=false`
|
||||
- **AND** 页面 MUST render the paginated `data.items` response
|
||||
|
||||
#### Scenario: Filter read notifications
|
||||
|
||||
- **WHEN** 用户选择已读筛选
|
||||
- **THEN** 前端 MUST send `is_read=true`
|
||||
|
||||
### Requirement: Unread Count And Summary
|
||||
|
||||
The admin header and notification drawer SHALL use `GET /api/admin/notifications/unread-count` and `GET /api/admin/notifications/unread-summary`. The unread count response SHALL preserve `count` and `display_count`; the summary response SHALL preserve `approval`, `expiry`, `sync`, `system`, and `total`.
|
||||
|
||||
#### Scenario: Display backend unread badge
|
||||
|
||||
- **WHEN** 未读数接口返回 `count` and `display_count`
|
||||
- **THEN** 顶部铃铛 MUST display the backend `display_count`
|
||||
- **AND** shared notification state MUST preserve the numeric `count`
|
||||
|
||||
#### Scenario: Load drawer notifications
|
||||
|
||||
- **WHEN** 用户打开顶部通知抽屉
|
||||
- **THEN** 前端 MUST use the summary endpoint for category counts
|
||||
- **AND** 前端 MUST query the notification list endpoint with `page=1` and `page_size=10` for recent notifications
|
||||
|
||||
### Requirement: Read State Operations
|
||||
|
||||
The admin frontend SHALL mark notifications read through `PUT /api/admin/notifications/{id}/read` and mark notifications read in bulk through `PUT /api/admin/notifications/read-all`. The bulk request SHALL support an optional JSON `category` field.
|
||||
|
||||
#### Scenario: Mark one notification read
|
||||
|
||||
- **WHEN** 用户点击通知
|
||||
- **THEN** 前端 MUST call the single-read PUT endpoint before target resolution
|
||||
- **AND** after success MUST refresh the shared unread count and relevant notification state
|
||||
|
||||
#### Scenario: Mark all notifications read
|
||||
|
||||
- **WHEN** 用户点击全部已读
|
||||
- **THEN** 前端 MUST call `PUT /api/admin/notifications/read-all` with an object body
|
||||
- **AND** after success MUST refresh the list, summary and unread count
|
||||
|
||||
### Requirement: Controlled Notification Target
|
||||
|
||||
The admin frontend SHALL resolve notification navigation through `GET /api/admin/notifications/{id}/target`. It SHALL map only recognized `target_type` or `target_key` values to registered internal routes and SHALL use `target_id` as a route parameter when required.
|
||||
|
||||
#### Scenario: Navigate to recognized target
|
||||
|
||||
- **WHEN** 目标接口返回可用的受支持 `target_type`、`target_key` 和 `target_id`
|
||||
- **THEN** 前端 MUST navigate to the corresponding registered internal route
|
||||
- **AND** 前端 MUST not execute an arbitrary URL from the response
|
||||
|
||||
#### Scenario: Handle unknown target safely
|
||||
|
||||
- **WHEN** 目标类型未知、目标不可用或当前路由未注册
|
||||
- **THEN** 前端 MUST not navigate
|
||||
- **AND** 页面 MUST display the notification body or an unavailable-target message
|
||||
|
||||
### Requirement: Admin-Only Notification Scope
|
||||
|
||||
This notification capability SHALL apply only to the admin APIs under `/api/admin/notifications` and the admin `/notifications` interface. It SHALL not add or modify C-end `/api/c/v1/notifications` APIs or pages.
|
||||
|
||||
#### Scenario: Exclude C-end notifications
|
||||
|
||||
- **WHEN** this change is implemented
|
||||
- **THEN** C-end notification APIs and pages MUST remain outside the change scope
|
||||
@@ -0,0 +1,34 @@
|
||||
## 1. Contract And Types
|
||||
|
||||
- [x] 1.1 按实际接口更新通知项、列表响应、未读数、未读汇总和受控目标类型。
|
||||
- [x] 1.2 将通知列表筛选参数从 `read_status` 改为 `is_read?: boolean`。
|
||||
- [x] 1.3 更新通知 service:未读数、未读汇总、列表、单条已读、全部已读和目标接口的请求/响应类型。
|
||||
|
||||
## 2. Shared Notification State
|
||||
|
||||
- [x] 2.1 使用 `data.count` 和 `data.display_count` 同步未读数量及顶部徽标。
|
||||
- [x] 2.2 使用 `{ approval, expiry, sync, system, total }` 保存分类汇总,并通过通知列表接口加载抽屉最近10条。
|
||||
- [x] 2.3 单条已读和全部已读成功后刷新真实未读数、汇总和相关通知状态,避免仅修改本地计数。
|
||||
|
||||
## 3. Header And Notification Center
|
||||
|
||||
- [x] 3.1 顶部铃铛展示后端 `display_count`,支持0、普通数量和 `99+`。
|
||||
- [x] 3.2 抽屉展示最近10条真实通知,支持全部、审批、临期、同步/系统分类和进入通知中心。
|
||||
- [x] 3.3 通知中心使用 `body`、`is_read`、`read_at` 展示正文、状态和已读时间。
|
||||
- [x] 3.4 通知中心筛选使用 `category`、`type`、`severity`、`is_read`、`page`、`page_size`。
|
||||
- [x] 3.5 严重级别支持 `info`、`warning`、`error`、`critical`,未知值稳定展示。
|
||||
- [x] 3.6 全部已读和单条已读严格按实际 PUT 接口调用。
|
||||
|
||||
## 4. Controlled Navigation And Scope
|
||||
|
||||
- [x] 4.1 根据 `target_type`、`target_key` 和 `target_id`实现前端内部目标白名单映射。
|
||||
- [x] 4.2 未知、无权限或不可用目标只展示通知正文,不执行任意 URL 或未注册路由跳转。
|
||||
- [x] 4.3 保留管理端通知中心路由和顶部入口,不新增 C 端通知接口、页面或逻辑。
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- [x] 5.1 验证真实通知字段 `body`、`is_read`、`read_at`、`ref_key` 正确展示和保存。
|
||||
- [x] 5.2 验证未读徽标、分类汇总、抽屉列表和通知中心状态一致。
|
||||
- [x] 5.3 验证 `is_read=true/false` 筛选及全部已读 `category` 请求体。
|
||||
- [x] 5.4 验证受控目标白名单和未知目标安全降级。
|
||||
- [x] 5.5 运行类型检查、lint、相关测试、构建和 `openspec validate update-admin-notification-center-api --strict`。
|
||||
@@ -7,7 +7,7 @@
|
||||
## What Changes
|
||||
|
||||
- 在店铺新建和编辑表单新增可清空、可搜索的“平台业务员”选择器。
|
||||
- 候选业务员复用启用的平台账号查询接口,展示账号名和手机号摘要。
|
||||
- 候选业务员使用店铺业务员候选接口,展示账号名和手机号摘要。
|
||||
- 扩展店铺创建、更新、列表查询、列表返回和详情返回的数据契约,支持 `business_owner_account_id` 及业务员展示字段。
|
||||
- 在店铺列表新增业务员列和按业务员筛选项,在店铺详情展示业务员名称和手机号摘要。
|
||||
- 已停用的已绑定业务员仅用于历史列表和详情展示,不能作为新建或编辑表单的候选项。
|
||||
@@ -26,4 +26,5 @@
|
||||
- `POST /api/admin/shops`
|
||||
- `PUT /api/admin/shops/{id}`
|
||||
- `GET /api/admin/shops`
|
||||
- `GET /api/admin/accounts?account_type=platform&status=1`
|
||||
- `GET /api/admin/shops/{id}`
|
||||
- `GET /api/admin/shops/business-owner-candidates`
|
||||
|
||||
@@ -27,7 +27,9 @@ The system SHALL allow users to assign or clear an optional platform salesperson
|
||||
#### Scenario: Do not expose disabled accounts as editable candidates
|
||||
|
||||
- **WHEN** 用户打开或搜索“平台业务员”选择器
|
||||
- **THEN** 系统 MUST 仅查询 `account_type=platform` 且 `status=1` 的账号
|
||||
- **THEN** 系统 MUST query `GET /api/admin/shops/business-owner-candidates`
|
||||
- **AND** 请求 MUST support `keyword`、`page` 和 `page_size`
|
||||
- **AND** 候选项 MUST use `id`、`username` and `phone_summary`
|
||||
- **AND** 已停用平台账号 MUST NOT 作为新建或编辑候选项显示
|
||||
|
||||
#### Scenario: No eligible salesperson exists
|
||||
@@ -45,7 +47,7 @@ The shop list SHALL display the assigned salesperson and support filtering by `b
|
||||
|
||||
- **WHEN** 用户查看店铺列表
|
||||
- **THEN** 列表 MUST 显示业务员列
|
||||
- **AND** 已绑定业务员的行 MUST 展示 `business_owner_name` 和 `business_owner_phone_masked`
|
||||
- **AND** 已绑定业务员的行 MUST 展示 `business_owner_username` 和 `business_owner_phone_summary`
|
||||
|
||||
#### Scenario: Filter shops by salesperson
|
||||
|
||||
@@ -57,7 +59,7 @@ The shop list SHALL display the assigned salesperson and support filtering by `b
|
||||
|
||||
- **GIVEN** 店铺历史上已绑定的平台业务员后来被停用
|
||||
- **WHEN** 用户查看店铺列表
|
||||
- **THEN** 列表 MUST 继续展示该店铺返回的 `business_owner_name` 和 `business_owner_phone_masked`
|
||||
- **THEN** 列表 MUST 继续展示该店铺返回的 `business_owner_username` 和 `business_owner_phone_summary`
|
||||
|
||||
### Requirement: Shop Detail Salesperson Display
|
||||
|
||||
@@ -72,7 +74,8 @@ The shop detail SHALL display the assigned salesperson name and masked phone fro
|
||||
|
||||
- **GIVEN** 店铺绑定的业务员已停用
|
||||
- **WHEN** 用户查看该店铺详情
|
||||
- **THEN** 页面 MUST 继续使用详情响应中的 `business_owner_name` 和 `business_owner_phone_masked` 展示历史业务员信息
|
||||
- **THEN** 页面 MUST request `GET /api/admin/shops/{id}`
|
||||
- **AND** 页面 MUST 继续使用详情响应中的 `business_owner_username` 和 `business_owner_phone_summary` 展示历史业务员信息
|
||||
|
||||
#### Scenario: Do not add distribution or commission semantics
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- [x] 1.1 更新店铺创建和编辑请求类型,支持可空的 `business_owner_account_id`。
|
||||
- [x] 1.2 更新店铺列表查询类型,支持 `business_owner_account_id` 筛选参数。
|
||||
- [x] 1.3 更新店铺列表和详情响应类型,读取业务员 ID、名称和脱敏手机号字段。
|
||||
- [x] 1.4 复用启用平台账号查询接口,读取 `account_id`、`account_name`、`phone_masked` 作为候选项。
|
||||
- [x] 1.4 接入店铺业务员候选接口,读取 `id`、`username`、`phone_summary` 作为候选项。
|
||||
|
||||
## 2. Shop Form UI
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ import { BaseService } from '../BaseService'
|
||||
import type {
|
||||
PlatformAccount,
|
||||
AccountQueryParams,
|
||||
PlatformSalesperson,
|
||||
PlatformSalespersonQueryParams,
|
||||
CreatePlatformAccountParams,
|
||||
BaseResponse,
|
||||
PaginationResponse
|
||||
@@ -25,16 +23,6 @@ export class AccountService extends BaseService {
|
||||
return this.getPage<PlatformAccount>('/api/admin/accounts', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用的平台业务员候选
|
||||
* GET /api/admin/accounts?account_type=platform&status=1
|
||||
*/
|
||||
static getPlatformSalespeople(
|
||||
params: PlatformSalespersonQueryParams
|
||||
): Promise<PaginationResponse<PlatformSalesperson>> {
|
||||
return this.getPage<PlatformSalesperson>('/api/admin/accounts', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建账号
|
||||
* POST /api/admin/accounts
|
||||
|
||||
@@ -31,7 +31,9 @@ import type {
|
||||
AssetOperationLogsParams,
|
||||
AssetPackageUsageRecord,
|
||||
UpdateAssetPackageUsedDataRequest,
|
||||
UpdateAssetPackageExpiresAtRequest
|
||||
UpdateAssetPackageExpiresAtRequest,
|
||||
ExpiringAssetItem,
|
||||
ExpiringAssetQueryParams
|
||||
} from '@/types/api'
|
||||
|
||||
const runRateLimitedAssetAction = async <T>(
|
||||
@@ -45,6 +47,19 @@ const runRateLimitedAssetAction = async <T>(
|
||||
}
|
||||
|
||||
export class AssetService extends BaseService {
|
||||
/**
|
||||
* 获取管理端临期资产列表
|
||||
* GET /api/admin/expiring-assets
|
||||
*/
|
||||
static getExpiringAssets(
|
||||
params?: ExpiringAssetQueryParams
|
||||
): Promise<
|
||||
BaseResponse<{ items: ExpiringAssetItem[]; total: number; page: number; size: number }>
|
||||
> {
|
||||
return this.get<
|
||||
BaseResponse<{ items: ExpiringAssetItem[]; total: number; page: number; size: number }>
|
||||
>('/api/admin/expiring-assets', params)
|
||||
}
|
||||
/**
|
||||
* 通过任意标识符查询设备或卡的完整详情
|
||||
* 支持虚拟号、ICCID、IMEI、SN、MSISDN
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
import type {
|
||||
BaseResponse,
|
||||
NotificationItem,
|
||||
NotificationListResponse,
|
||||
NotificationQueryParams,
|
||||
NotificationSummary,
|
||||
NotificationReadAllRequest,
|
||||
NotificationReadAllResponse,
|
||||
NotificationReadResponse,
|
||||
NotificationUnreadCount,
|
||||
NotificationUnreadSummary,
|
||||
NotificationTarget
|
||||
} from '@/types/api'
|
||||
|
||||
export class NotificationService extends BaseService {
|
||||
static getUnreadCount(): Promise<
|
||||
BaseResponse<number | { count?: number; unread_count?: number }>
|
||||
> {
|
||||
return this.get<BaseResponse<number | { count?: number; unread_count?: number }>>(
|
||||
'/api/admin/notifications/unread-count'
|
||||
)
|
||||
static getUnreadCount(): Promise<BaseResponse<NotificationUnreadCount>> {
|
||||
return this.get<BaseResponse<NotificationUnreadCount>>('/api/admin/notifications/unread-count')
|
||||
}
|
||||
|
||||
static getUnreadSummary(): Promise<BaseResponse<NotificationSummary>> {
|
||||
return this.get<BaseResponse<NotificationSummary>>('/api/admin/notifications/unread-summary')
|
||||
static getUnreadSummary(): Promise<BaseResponse<NotificationUnreadSummary>> {
|
||||
return this.get<BaseResponse<NotificationUnreadSummary>>(
|
||||
'/api/admin/notifications/unread-summary'
|
||||
)
|
||||
}
|
||||
|
||||
static getNotifications(
|
||||
@@ -27,15 +28,23 @@ export class NotificationService extends BaseService {
|
||||
return this.get<BaseResponse<NotificationListResponse>>('/api/admin/notifications', params)
|
||||
}
|
||||
|
||||
static markRead(id: number | string): Promise<BaseResponse<NotificationItem>> {
|
||||
return this.put<BaseResponse<NotificationItem>>(`/api/admin/notifications/${id}/read`, {})
|
||||
static markRead(id: number): Promise<BaseResponse<NotificationReadResponse>> {
|
||||
return this.put<BaseResponse<NotificationReadResponse>>(
|
||||
`/api/admin/notifications/${id}/read`,
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
static markAllRead(): Promise<BaseResponse> {
|
||||
return this.put<BaseResponse>('/api/admin/notifications/read-all', {})
|
||||
static markAllRead(
|
||||
data: NotificationReadAllRequest = {}
|
||||
): Promise<BaseResponse<NotificationReadAllResponse>> {
|
||||
return this.put<BaseResponse<NotificationReadAllResponse>>(
|
||||
'/api/admin/notifications/read-all',
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
static getTarget(id: number | string): Promise<BaseResponse<NotificationTarget>> {
|
||||
static getTarget(id: number): Promise<BaseResponse<NotificationTarget>> {
|
||||
return this.get<BaseResponse<NotificationTarget>>(`/api/admin/notifications/${id}/target`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ import type {
|
||||
AssignShopRolesRequest,
|
||||
UpdateShopCreditLimitRequest,
|
||||
BaseResponse,
|
||||
PaginationResponse
|
||||
PaginationResponse,
|
||||
ShopBusinessOwnerCandidate,
|
||||
ShopBusinessOwnerCandidateQueryParams
|
||||
} from '@/types/api'
|
||||
|
||||
export class ShopService extends BaseService {
|
||||
@@ -25,6 +27,27 @@ export class ShopService extends BaseService {
|
||||
return this.getPage<ShopResponse>('/api/admin/shops', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取店铺业务员候选
|
||||
* GET /api/admin/shops/business-owner-candidates
|
||||
*/
|
||||
static getBusinessOwnerCandidates(
|
||||
params?: ShopBusinessOwnerCandidateQueryParams
|
||||
): Promise<PaginationResponse<ShopBusinessOwnerCandidate>> {
|
||||
return this.getPage<ShopBusinessOwnerCandidate>(
|
||||
'/api/admin/shops/business-owner-candidates',
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取店铺详情
|
||||
* GET /api/admin/shops/{id}
|
||||
*/
|
||||
static getShopDetail(id: number): Promise<BaseResponse<ShopResponse>> {
|
||||
return this.getOne<ShopResponse>(`/api/admin/shops/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 店铺级联查询(树结构)
|
||||
* GET /api/admin/shops/cascade
|
||||
|
||||
@@ -238,9 +238,7 @@
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const unreadCount = computed(() => notificationStore.unreadCount)
|
||||
const formattedUnreadCount = computed(() =>
|
||||
unreadCount.value > 99 ? '99+' : String(unreadCount.value)
|
||||
)
|
||||
const formattedUnreadCount = computed(() => notificationStore.displayCount)
|
||||
|
||||
const menuTopWidth = computed(() => {
|
||||
return width.value * 0.5
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</span>
|
||||
<span class="text">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>{{ item.content }}</small>
|
||||
<small>{{ item.body }}</small>
|
||||
<time>{{ item.created_at || '-' }}</time>
|
||||
</span>
|
||||
<span v-if="!isRead(item)" class="unread-dot" />
|
||||
@@ -85,23 +85,23 @@
|
||||
return item.category === category
|
||||
}
|
||||
|
||||
const isRead = (item: NotificationItem) =>
|
||||
item.read === true || item.is_read === true || item.read_status === 'read'
|
||||
const isRead = (item: NotificationItem) => item.is_read
|
||||
|
||||
const filteredItems = computed(() =>
|
||||
notificationStore.summary.items.filter((item) => categoryMatches(item, activeCategory.value))
|
||||
notificationStore.recentNotifications.filter((item) =>
|
||||
categoryMatches(item, activeCategory.value)
|
||||
)
|
||||
)
|
||||
|
||||
const tabs = computed(() => {
|
||||
const categories = notificationStore.summary.categories || {}
|
||||
return [
|
||||
{ value: 'all', label: '全部', count: notificationStore.unreadCount },
|
||||
{ value: 'approval', label: '审批', count: categories.approval || 0 },
|
||||
{ value: 'expiry', label: '临期', count: categories.expiry || categories.expiring || 0 },
|
||||
{ value: 'all', label: '全部', count: notificationStore.summary.total },
|
||||
{ value: 'approval', label: '审批', count: notificationStore.summary.approval },
|
||||
{ value: 'expiry', label: '临期', count: notificationStore.summary.expiry },
|
||||
{
|
||||
value: 'system',
|
||||
label: '同步/系统',
|
||||
count: (categories.sync || 0) + (categories.system || 0)
|
||||
count: notificationStore.summary.sync + notificationStore.summary.system
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -135,11 +135,11 @@
|
||||
}
|
||||
const targetResponse = await NotificationService.getTarget(item.id)
|
||||
if (targetResponse.code !== 0 || !targetResponse.data) {
|
||||
ElMessage.info(item.content || '该通知暂无可跳转目标')
|
||||
ElMessage.info(item.body || '该通知暂无可跳转目标')
|
||||
return
|
||||
}
|
||||
if (!navigateNotificationTarget(router, targetResponse.data)) {
|
||||
ElMessage.info(targetResponse.data.message || item.content || '该通知暂无可跳转目标')
|
||||
ElMessage.info(item.body || '该通知暂无可跳转目标')
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
|
||||
@@ -108,9 +108,9 @@
|
||||
background: var(--el-color-danger-light-9);
|
||||
}
|
||||
|
||||
&.severity-success {
|
||||
color: var(--el-color-success);
|
||||
background: var(--el-color-success-light-9);
|
||||
&.severity-critical {
|
||||
color: var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
name: 'Notifications',
|
||||
component: RoutesAlias.Home,
|
||||
meta: {
|
||||
title: '通知中心'
|
||||
title: '通知中心',
|
||||
isHide: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
@@ -30,6 +31,29 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
meta: {
|
||||
title: '通知中心',
|
||||
permissions: [NOTIFICATION_PERMISSIONS.page],
|
||||
keepAlive: true,
|
||||
isHide: true,
|
||||
isHideTab: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 临期资产
|
||||
{
|
||||
path: '/operations',
|
||||
name: 'Operations',
|
||||
component: RoutesAlias.Home,
|
||||
meta: {
|
||||
title: '运营管理'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'expiring-assets',
|
||||
name: 'ExpiringAssets',
|
||||
component: RoutesAlias.ExpiringAssets,
|
||||
meta: {
|
||||
title: '临期资产',
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ export enum RoutesAlias {
|
||||
|
||||
// 站内通知
|
||||
Notifications = '/notifications', // 通知中心
|
||||
ExpiringAssets = '/operations/expiring-assets', // 临期资产
|
||||
|
||||
// 佣金管理
|
||||
WithdrawalApproval = '/commission-management/withdrawal-approval', // 提现审批
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { NotificationService } from '@/api/modules/notification'
|
||||
import type { NotificationSummary } from '@/types/api'
|
||||
import type { NotificationItem, NotificationUnreadSummary } from '@/types/api'
|
||||
|
||||
const getCountValue = (data: number | { count?: number; unread_count?: number } | undefined) => {
|
||||
if (typeof data === 'number') return Math.max(0, data)
|
||||
return Math.max(0, data?.unread_count ?? data?.count ?? 0)
|
||||
}
|
||||
const emptySummary = (): NotificationUnreadSummary => ({
|
||||
approval: 0,
|
||||
expiry: 0,
|
||||
sync: 0,
|
||||
system: 0,
|
||||
total: 0
|
||||
})
|
||||
|
||||
export const useNotificationStore = defineStore('notificationStore', () => {
|
||||
const unreadCount = ref(0)
|
||||
const summary = ref<NotificationSummary>({ items: [], categories: {}, total_unread: 0 })
|
||||
const displayCount = ref('0')
|
||||
const summary = ref<NotificationUnreadSummary>(emptySummary())
|
||||
const recentNotifications = ref<NotificationItem[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const refreshUnreadCount = async () => {
|
||||
const response = await NotificationService.getUnreadCount()
|
||||
if (response.code === 0) {
|
||||
unreadCount.value = getCountValue(response.data)
|
||||
if (response.code === 0 && response.data) {
|
||||
unreadCount.value = Math.max(0, response.data.count)
|
||||
displayCount.value = response.data.display_count || '0'
|
||||
}
|
||||
return unreadCount.value
|
||||
}
|
||||
@@ -24,15 +30,15 @@ export const useNotificationStore = defineStore('notificationStore', () => {
|
||||
const refreshSummary = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await NotificationService.getUnreadSummary()
|
||||
if (response.code === 0 && response.data) {
|
||||
summary.value = {
|
||||
...response.data,
|
||||
items: (response.data.items || []).slice(0, 10)
|
||||
}
|
||||
if (response.data.total_unread !== undefined) {
|
||||
unreadCount.value = Math.max(0, response.data.total_unread)
|
||||
const [summaryResponse, listResponse] = await Promise.all([
|
||||
NotificationService.getUnreadSummary(),
|
||||
NotificationService.getNotifications({ page: 1, page_size: 10 })
|
||||
])
|
||||
if (summaryResponse.code === 0 && summaryResponse.data) {
|
||||
summary.value = summaryResponse.data
|
||||
}
|
||||
if (listResponse.code === 0 && listResponse.data) {
|
||||
recentNotifications.value = listResponse.data.items.slice(0, 10)
|
||||
}
|
||||
await refreshUnreadCount()
|
||||
return summary.value
|
||||
@@ -41,46 +47,27 @@ export const useNotificationStore = defineStore('notificationStore', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const markRead = async (id: number | string) => {
|
||||
const markRead = async (id: number) => {
|
||||
const response = await NotificationService.markRead(id)
|
||||
if (response.code === 0) {
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
summary.value = {
|
||||
...summary.value,
|
||||
items: summary.value.items.map((item) =>
|
||||
item.id === id ? { ...item, read: true, is_read: true, read_status: 'read' } : item
|
||||
)
|
||||
}
|
||||
await refreshUnreadCount()
|
||||
await refreshSummary()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
const markAllRead = async () => {
|
||||
const response = await NotificationService.markAllRead()
|
||||
const markAllRead = async (category?: string) => {
|
||||
const response = await NotificationService.markAllRead(category ? { category } : {})
|
||||
if (response.code === 0) {
|
||||
unreadCount.value = 0
|
||||
summary.value = {
|
||||
...summary.value,
|
||||
total_unread: 0,
|
||||
categories: Object.fromEntries(
|
||||
Object.keys(summary.value.categories || {}).map((key) => [key, 0])
|
||||
),
|
||||
items: summary.value.items.map((item) => ({
|
||||
...item,
|
||||
read: true,
|
||||
is_read: true,
|
||||
read_status: 'read'
|
||||
}))
|
||||
}
|
||||
await refreshUnreadCount()
|
||||
await refreshSummary()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
return {
|
||||
unreadCount,
|
||||
displayCount,
|
||||
summary,
|
||||
recentNotifications,
|
||||
loading,
|
||||
refreshUnreadCount,
|
||||
refreshSummary,
|
||||
|
||||
@@ -107,20 +107,6 @@ export interface AccountQueryParams extends PaginationParams {
|
||||
enterprise_id?: number // 按企业ID筛选
|
||||
}
|
||||
|
||||
// 平台业务员候选账号
|
||||
export interface PlatformSalesperson {
|
||||
account_id: number
|
||||
account_name: string
|
||||
phone_masked: string
|
||||
}
|
||||
|
||||
// 平台业务员候选查询参数
|
||||
export interface PlatformSalespersonQueryParams extends PaginationParams {
|
||||
account_type: 'platform'
|
||||
status: AccountStatus
|
||||
account_name?: string
|
||||
}
|
||||
|
||||
// 代理商查询参数
|
||||
export interface AgentQueryParams extends PaginationParams {
|
||||
keyword?: string
|
||||
|
||||
@@ -69,6 +69,35 @@ export type AssetRealnamePolicy = 'none' | 'before_order' | 'after_order'
|
||||
// 预计最终到期时间状态
|
||||
export type ExpiryEstimateStatus = 'exact' | 'waiting_activation' | 'none' | 'invalid_data'
|
||||
|
||||
// 临期等级由后端返回,前端仅用于展示和筛选结果标识
|
||||
export type ExpiryLevel = string
|
||||
|
||||
export interface ExpiringAssetItem {
|
||||
asset_type: AssetType
|
||||
asset_id: number
|
||||
identifier: string
|
||||
shop_name?: string | null
|
||||
package_name?: string | null
|
||||
estimated_final_expires_at: string
|
||||
days_until_final_expiry: number
|
||||
expiry_level?: ExpiryLevel | null
|
||||
expiry_level_name?: string | null
|
||||
can_renew: boolean
|
||||
}
|
||||
|
||||
export interface ExpiringAssetQueryParams {
|
||||
asset_type?: AssetType
|
||||
keyword?: string
|
||||
shop_id?: number
|
||||
package_id?: number
|
||||
days_min?: number
|
||||
days_max?: number
|
||||
expires_from?: string
|
||||
expires_to?: string
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
// 批量更新资产实名认证策略请求
|
||||
export interface BatchUpdateAssetRealnamePolicyRequest {
|
||||
asset_ids: number[]
|
||||
|
||||
@@ -405,6 +405,9 @@ export interface StandaloneIotCard {
|
||||
expiry_estimate_status?: ExpiryEstimateStatus // 预计最终到期时间状态
|
||||
expiry_estimate_status_name?: string | null // 预计最终到期时间状态名称
|
||||
is_expiring?: boolean // 是否临期,由后端判断
|
||||
expiry_level?: string | null // 临期等级
|
||||
expiry_level_name?: string | null // 临期等级名称
|
||||
can_renew?: boolean // 是否允许续费
|
||||
last_month_total_mb?: number // 上月流量总量(MB)
|
||||
last_data_check_at?: string | null // 最后流量检查时间
|
||||
last_real_name_check_at?: string | null // 最后实名检查时间
|
||||
|
||||
@@ -73,6 +73,9 @@ export interface Device {
|
||||
expiry_estimate_status?: ExpiryEstimateStatus // 预计最终到期时间状态
|
||||
expiry_estimate_status_name?: string | null // 预计最终到期时间状态名称
|
||||
is_expiring?: boolean // 是否临期,由后端判断
|
||||
expiry_level?: string | null // 临期等级
|
||||
expiry_level_name?: string | null // 临期等级名称
|
||||
can_renew?: boolean // 是否允许续费
|
||||
authorized_enterprise_id?: number | null // 当前有效授权企业ID
|
||||
authorized_enterprise_name?: string | null // 当前有效授权企业名称
|
||||
}
|
||||
|
||||
@@ -1,51 +1,65 @@
|
||||
export type NotificationCategory = 'approval' | 'expiry' | 'sync' | 'system' | 'balance' | string
|
||||
export type NotificationReadStatus = 'read' | 'unread' | string
|
||||
export type NotificationCategory = 'approval' | 'expiry' | 'sync' | 'system' | string
|
||||
export type NotificationSeverity = 'info' | 'warning' | 'error' | 'critical' | string
|
||||
|
||||
export interface NotificationItem {
|
||||
id: number | string
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
category?: NotificationCategory | null
|
||||
type?: string | null
|
||||
severity?: string | null
|
||||
read?: boolean
|
||||
is_read?: boolean
|
||||
read_status?: NotificationReadStatus | null
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
ref_type?: string | null
|
||||
ref_id?: number | string | null
|
||||
[key: string]: unknown
|
||||
body: string
|
||||
category: NotificationCategory
|
||||
type: string
|
||||
severity: NotificationSeverity
|
||||
is_read: boolean
|
||||
created_at: string
|
||||
read_at: string | null
|
||||
ref_type: string | null
|
||||
ref_id: string | null
|
||||
ref_key: string | null
|
||||
}
|
||||
|
||||
export interface NotificationQueryParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
category?: string
|
||||
type?: string
|
||||
severity?: string
|
||||
read_status?: string
|
||||
is_read?: boolean
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
items: NotificationItem[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface NotificationSummary {
|
||||
items: NotificationItem[]
|
||||
categories?: Record<string, number>
|
||||
total_unread?: number
|
||||
export interface NotificationUnreadCount {
|
||||
count: number
|
||||
display_count: string
|
||||
}
|
||||
|
||||
export interface NotificationUnreadSummary {
|
||||
approval: number
|
||||
expiry: number
|
||||
sync: number
|
||||
system: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface NotificationReadResponse {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface NotificationReadAllRequest {
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface NotificationReadAllResponse {
|
||||
updated_count: number
|
||||
}
|
||||
|
||||
export interface NotificationTarget {
|
||||
available?: boolean
|
||||
route_name?: string | null
|
||||
route_path?: string | null
|
||||
route_params?: Record<string, string | number> | null
|
||||
query?: Record<string, string | number | boolean> | null
|
||||
message?: string | null
|
||||
available: boolean
|
||||
target_id: number | null
|
||||
target_key: string
|
||||
target_type: string
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@ export interface ShopResponse {
|
||||
contact_name: string // 联系人姓名
|
||||
contact_phone: string // 联系人电话
|
||||
status: number // 状态 (0:禁用, 1:启用)
|
||||
business_owner_account_id?: number | null // 平台业务员账号ID
|
||||
business_owner_name?: string // 平台业务员名称
|
||||
business_owner_phone_masked?: string // 平台业务员脱敏手机号
|
||||
business_owner_account_id: number | null // 平台业务员账号ID
|
||||
business_owner_username: string // 平台业务员账号名
|
||||
business_owner_phone_summary: string // 平台业务员手机号摘要
|
||||
business_owner_available: boolean // 是否仍可用于通知接收
|
||||
}
|
||||
|
||||
// 店铺列表查询参数
|
||||
@@ -71,6 +72,18 @@ export interface UpdateShopParams {
|
||||
business_owner_account_id?: number | null // 平台业务员账号ID
|
||||
}
|
||||
|
||||
// 店铺业务员候选
|
||||
export interface ShopBusinessOwnerCandidate {
|
||||
id: number // 平台业务员账号ID
|
||||
username: string // 平台业务员账号名
|
||||
phone_summary: string // 手机号摘要
|
||||
}
|
||||
|
||||
// 店铺业务员候选查询参数
|
||||
export interface ShopBusinessOwnerCandidateQueryParams extends PaginationParams {
|
||||
keyword?: string // 用户名或手机号关键词
|
||||
}
|
||||
|
||||
// 店铺列表分页响应
|
||||
export interface ShopPageResult {
|
||||
items: ShopResponse[] | null // 店铺列表
|
||||
|
||||
@@ -29,3 +29,11 @@ export const getExpiryEstimateTooltip = (estimate: ExpiryEstimate): string => {
|
||||
if (!estimate.is_expiring || estimate.days_until_final_expiry == null) return ''
|
||||
return `剩余${estimate.days_until_final_expiry}天`
|
||||
}
|
||||
|
||||
export const getExpiryEstimateClass = (estimate: ExpiryEstimate): string => {
|
||||
if (!estimate.is_expiring || estimate.days_until_final_expiry == null) return ''
|
||||
if (estimate.days_until_final_expiry <= 3) return 'expiry-estimate--critical'
|
||||
if (estimate.days_until_final_expiry <= 7) return 'expiry-estimate--warning'
|
||||
if (estimate.days_until_final_expiry <= 15) return 'expiry-estimate--notice'
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import type { NotificationTarget } from '@/types/api'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
|
||||
export const navigateNotificationTarget = (router: Router, target?: NotificationTarget | null) => {
|
||||
if (!target || target.available === false) return false
|
||||
const query = target.query
|
||||
? Object.fromEntries(Object.entries(target.query).map(([key, value]) => [key, String(value)]))
|
||||
: undefined
|
||||
type TargetRoute = {
|
||||
path: string
|
||||
query?: Record<string, string>
|
||||
}
|
||||
|
||||
if (target.route_name) {
|
||||
if (!router.hasRoute(target.route_name)) return false
|
||||
void router.push({
|
||||
name: target.route_name,
|
||||
params: target.route_params || undefined,
|
||||
query
|
||||
})
|
||||
return true
|
||||
const resolveTargetRoute = (target: NotificationTarget): TargetRoute | null => {
|
||||
const targetValues = [target.target_key, target.target_type]
|
||||
|
||||
if (targetValues.includes('expiring_assets') || targetValues.includes('expiring_asset_list')) {
|
||||
return { path: RoutesAlias.ExpiringAssets }
|
||||
}
|
||||
|
||||
const path = target.route_path || ''
|
||||
if (!path.startsWith('/') || path.startsWith('//') || /^https?:\/\//i.test(path)) return false
|
||||
if (targetValues.includes('notification_center')) return { path: RoutesAlias.Notifications }
|
||||
|
||||
const resolved = router.resolve({
|
||||
path,
|
||||
query
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
export const navigateNotificationTarget = (router: Router, target?: NotificationTarget | null) => {
|
||||
if (!target?.available) return false
|
||||
const route = resolveTargetRoute(target)
|
||||
if (!route) return false
|
||||
|
||||
const resolved = router.resolve({ path: route.path, query: route.query })
|
||||
if (!resolved.matched.length || resolved.name === 'Exception404') return false
|
||||
|
||||
void router.push(resolved)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="预计套餐到期时间">
|
||||
<span
|
||||
:class="{ 'expiry-estimate--expiring': cardInfo?.is_expiring }"
|
||||
:class="getExpiryEstimateClass(cardInfo)"
|
||||
:title="getExpiryEstimateTooltip(cardInfo)"
|
||||
>
|
||||
{{ getExpiryEstimateText(cardInfo) }}
|
||||
@@ -182,7 +182,7 @@
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="预计套餐到期时间">
|
||||
<span
|
||||
:class="{ 'expiry-estimate--expiring': cardInfo?.is_expiring }"
|
||||
:class="getExpiryEstimateClass(cardInfo)"
|
||||
:title="getExpiryEstimateTooltip(cardInfo)"
|
||||
>
|
||||
{{ getExpiryEstimateText(cardInfo) }}
|
||||
@@ -598,7 +598,11 @@
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { getExpiryEstimateText, getExpiryEstimateTooltip } from '@/utils/business/expiryEstimate'
|
||||
import {
|
||||
getExpiryEstimateText,
|
||||
getExpiryEstimateTooltip,
|
||||
getExpiryEstimateClass
|
||||
} from '@/utils/business/expiryEstimate'
|
||||
|
||||
// Props
|
||||
interface BindingCard {
|
||||
@@ -987,11 +991,24 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.expiry-estimate--expiring {
|
||||
.expiry-estimate--critical,
|
||||
.expiry-estimate--warning,
|
||||
.expiry-estimate--notice {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.expiry-estimate--critical {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.expiry-estimate--warning {
|
||||
color: #8e44ad;
|
||||
}
|
||||
|
||||
.expiry-estimate--notice {
|
||||
color: #e83e8c;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -995,7 +995,11 @@
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { getExpiryEstimateText, getExpiryEstimateTooltip } from '@/utils/business/expiryEstimate'
|
||||
import {
|
||||
getExpiryEstimateText,
|
||||
getExpiryEstimateTooltip,
|
||||
getExpiryEstimateClass
|
||||
} from '@/utils/business/expiryEstimate'
|
||||
import type { PackageSeriesResponse } from '@/types/api'
|
||||
import type { EnterpriseItem } from '@/types/api/enterprise'
|
||||
import type {
|
||||
@@ -1900,7 +1904,7 @@
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class: { 'expiry-estimate--expiring': row.is_expiring },
|
||||
class: getExpiryEstimateClass(row),
|
||||
title: getExpiryEstimateTooltip(row)
|
||||
},
|
||||
getExpiryEstimateText(row)
|
||||
@@ -3218,8 +3222,21 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.expiry-estimate--expiring {
|
||||
.expiry-estimate--critical,
|
||||
.expiry-estimate--warning,
|
||||
.expiry-estimate--notice {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.expiry-estimate--critical {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.expiry-estimate--warning {
|
||||
color: #8e44ad;
|
||||
}
|
||||
|
||||
.expiry-estimate--notice {
|
||||
color: #e83e8c;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1045,7 +1045,11 @@
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { getExpiryEstimateText, getExpiryEstimateTooltip } from '@/utils/business/expiryEstimate'
|
||||
import {
|
||||
getExpiryEstimateText,
|
||||
getExpiryEstimateTooltip,
|
||||
getExpiryEstimateClass
|
||||
} from '@/utils/business/expiryEstimate'
|
||||
import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/apiRateLimit'
|
||||
import type {
|
||||
StandaloneIotCard,
|
||||
@@ -2127,7 +2131,7 @@
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class: { 'expiry-estimate--expiring': row.is_expiring },
|
||||
class: getExpiryEstimateClass(row),
|
||||
title: getExpiryEstimateTooltip(row)
|
||||
},
|
||||
getExpiryEstimateText(row)
|
||||
@@ -3044,8 +3048,21 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.expiry-estimate--expiring {
|
||||
.expiry-estimate--critical,
|
||||
.expiry-estimate--warning,
|
||||
.expiry-estimate--notice {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.expiry-estimate--critical {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.expiry-estimate--warning {
|
||||
color: #8e44ad;
|
||||
}
|
||||
|
||||
.expiry-estimate--notice {
|
||||
color: #e83e8c;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="console">
|
||||
开发中敬请期待...
|
||||
<ExpiringAssetStats />
|
||||
<!--<CardList></CardList>-->
|
||||
|
||||
<!--<el-row :gutter="20">-->
|
||||
@@ -29,13 +29,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CardList from './widget/CardList.vue'
|
||||
import ActiveUser from './widget/ActiveUser.vue'
|
||||
import SalesOverview from './widget/SalesOverview.vue'
|
||||
import NewUser from './widget/NewUser.vue'
|
||||
import Dynamic from './widget/Dynamic.vue'
|
||||
import TodoList from './widget/TodoList.vue'
|
||||
import AboutProject from './widget/AboutProject.vue'
|
||||
import ExpiringAssetStats from './widget/ExpiringAssetStats.vue'
|
||||
import { useCommon } from '@/composables/useCommon'
|
||||
|
||||
defineOptions({ name: 'Console' })
|
||||
|
||||
102
src/views/dashboard/console/widget/ExpiringAssetStats.vue
Normal file
102
src/views/dashboard/console/widget/ExpiringAssetStats.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<ElCard shadow="never" class="expiring-asset-stats">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>临期资产</span>
|
||||
<ElButton link type="primary" @click="() => goToList()">查看列表</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item stat-item--card" @click="() => goToList('card')">
|
||||
<div class="stat-label">临期网卡</div>
|
||||
<div class="stat-value">{{ counts.card.toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="stat-item stat-item--device" @click="() => goToList('device')">
|
||||
<div class="stat-label">临期设备</div>
|
||||
<div class="stat-value">{{ counts.device.toLocaleString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AssetService } from '@/api/modules'
|
||||
import type { AssetType } from '@/types/api'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
|
||||
defineOptions({ name: 'ExpiringAssetStats' })
|
||||
|
||||
const router = useRouter()
|
||||
const counts = reactive<Record<AssetType, number>>({ card: 0, device: 0 })
|
||||
|
||||
const loadCount = async (assetType: AssetType) => {
|
||||
try {
|
||||
const response = await AssetService.getExpiringAssets({
|
||||
asset_type: assetType,
|
||||
page: 1,
|
||||
size: 1
|
||||
})
|
||||
if (response.code === 0 && response.data) counts[assetType] = response.data.total || 0
|
||||
} catch {
|
||||
counts[assetType] = 0
|
||||
}
|
||||
}
|
||||
|
||||
const goToList = (assetType?: AssetType) => {
|
||||
router.push({
|
||||
path: RoutesAlias.ExpiringAssets,
|
||||
query: assetType ? { asset_type: assetType } : {}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => void Promise.all([loadCount('card'), loadCount('device')]))
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.expiring-asset-stats {
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-item--card {
|
||||
background: #fff0f6;
|
||||
}
|
||||
|
||||
.stat-item--device {
|
||||
background: #f4ecff;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -20,10 +20,10 @@
|
||||
|
||||
<div class="filters">
|
||||
<ElSelect v-model="filters.category" clearable placeholder="通知分类" @change="reload">
|
||||
<ElOption label="余额预警" value="balance" />
|
||||
<ElOption label="临期提醒" value="expiry" />
|
||||
<ElOption label="审批" value="approval" />
|
||||
<ElOption label="同步/系统" value="system" />
|
||||
<ElOption label="同步" value="sync" />
|
||||
<ElOption label="系统" value="system" />
|
||||
</ElSelect>
|
||||
<ElSelect v-model="filters.type" clearable placeholder="通知类型" @change="reload">
|
||||
<ElOption v-for="type in typeOptions" :key="type" :label="type" :value="type" />
|
||||
@@ -32,11 +32,11 @@
|
||||
<ElOption label="提示" value="info" />
|
||||
<ElOption label="警告" value="warning" />
|
||||
<ElOption label="严重" value="error" />
|
||||
<ElOption label="成功" value="success" />
|
||||
<ElOption label="严重" value="critical" />
|
||||
</ElSelect>
|
||||
<ElSelect v-model="filters.read_status" clearable placeholder="已读状态" @change="reload">
|
||||
<ElOption label="未读" value="unread" />
|
||||
<ElOption label="已读" value="read" />
|
||||
<ElSelect v-model="filters.is_read" clearable placeholder="已读状态" @change="reload">
|
||||
<ElOption label="未读" :value="false" />
|
||||
<ElOption label="已读" :value="true" />
|
||||
</ElSelect>
|
||||
<ElButton @click="loadNotifications">刷新</ElButton>
|
||||
</div>
|
||||
@@ -59,7 +59,8 @@
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="content" label="通知内容" min-width="300" show-overflow-tooltip />
|
||||
<ElTableColumn prop="body" label="通知内容" min-width="300" show-overflow-tooltip />
|
||||
<ElTableColumn prop="read_at" label="已读时间" width="180" />
|
||||
<ElTableColumn prop="created_at" label="时间" width="180" />
|
||||
<ElTableColumn label="操作" width="100" fixed="right">
|
||||
<template #default="scope">
|
||||
@@ -118,11 +119,10 @@
|
||||
category: undefined,
|
||||
type: undefined,
|
||||
severity: undefined,
|
||||
read_status: undefined
|
||||
is_read: undefined
|
||||
})
|
||||
|
||||
const isRead = (item: NotificationItem) =>
|
||||
item.read === true || item.is_read === true || item.read_status === 'read'
|
||||
const isRead = (item: NotificationItem) => item.is_read
|
||||
|
||||
const getCategoryLabel = (category?: string | null) =>
|
||||
({
|
||||
@@ -137,14 +137,14 @@
|
||||
'-'
|
||||
|
||||
const getSeverityLabel = (severity?: string | null) =>
|
||||
({ info: '提示', warning: '警告', error: '严重', success: '成功' })[severity || ''] ||
|
||||
({ info: '提示', warning: '警告', error: '严重', critical: '严重' })[severity || ''] ||
|
||||
severity ||
|
||||
'-'
|
||||
|
||||
const getSeverityType = (severity?: string | null) => {
|
||||
if (severity === 'error') return 'danger'
|
||||
if (severity === 'warning') return 'warning'
|
||||
if (severity === 'success') return 'success'
|
||||
if (severity === 'critical') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
@@ -204,12 +204,12 @@
|
||||
}
|
||||
const targetResponse = await NotificationService.getTarget(item.id)
|
||||
if (targetResponse.code !== 0 || !targetResponse.data) {
|
||||
ElMessage.info(item.content || '该通知暂无可跳转目标')
|
||||
ElMessage.info(item.body || '该通知暂无可跳转目标')
|
||||
await loadNotifications()
|
||||
return
|
||||
}
|
||||
if (!navigateNotificationTarget(router, targetResponse.data)) {
|
||||
ElMessage.info(targetResponse.data.message || item.content || '该通知暂无可跳转目标')
|
||||
ElMessage.info(item.body || '该通知暂无可跳转目标')
|
||||
}
|
||||
await loadNotifications()
|
||||
} catch (error: any) {
|
||||
|
||||
364
src/views/operations/expiring-assets/index.vue
Normal file
364
src/views/operations/expiring-assets/index.vue
Normal file
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="expiring-assets-page" id="table-full-screen">
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
:show-expand="true"
|
||||
label-width="85"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<ArtTableHeader
|
||||
:columnList="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #left>
|
||||
<div class="page-intro"> 仅展示可预计且尚未过期的资产,0-3 天资产优先显示。 </div>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
row-key="asset_id"
|
||||
:loading="loading"
|
||||
:data="items"
|
||||
:currentPage="pagination.currentPage"
|
||||
:pageSize="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:actionsWidth="140"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, reactive, ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElTag } from 'element-plus'
|
||||
import { AssetService, PackageManageService, ShopService } from '@/api/modules'
|
||||
import type {
|
||||
AssetType,
|
||||
ExpiringAssetItem,
|
||||
ExpiringAssetQueryParams,
|
||||
PackageResponse,
|
||||
ShopResponse
|
||||
} from '@/types/api'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
|
||||
defineOptions({ name: 'ExpiringAssets' })
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const tableRef = ref()
|
||||
const loading = ref(false)
|
||||
const shopLoading = ref(false)
|
||||
const packageLoading = ref(false)
|
||||
const shopOptions = ref<ShopResponse[]>([])
|
||||
const packageOptions = ref<PackageResponse[]>([])
|
||||
const items = ref<ExpiringAssetItem[]>([])
|
||||
const searchForm = reactive<ExpiringAssetQueryParams>({
|
||||
asset_type: undefined,
|
||||
keyword: '',
|
||||
shop_id: undefined,
|
||||
package_id: undefined,
|
||||
days_min: undefined,
|
||||
days_max: undefined,
|
||||
expires_from: undefined,
|
||||
expires_to: undefined
|
||||
})
|
||||
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 })
|
||||
|
||||
const searchFormItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: '资产类型',
|
||||
prop: 'asset_type',
|
||||
type: 'select',
|
||||
config: { clearable: true, placeholder: '全部' },
|
||||
options: [
|
||||
{ label: '网卡', value: 'card' },
|
||||
{ label: '设备', value: 'device' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '关键词',
|
||||
prop: 'keyword',
|
||||
type: 'input',
|
||||
config: { clearable: true, placeholder: '请输入资产标识' }
|
||||
},
|
||||
{
|
||||
label: '店铺',
|
||||
prop: 'shop_id',
|
||||
type: 'select',
|
||||
config: {
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
remote: true,
|
||||
loading: shopLoading.value,
|
||||
remoteMethod: (query: string) => searchShops(query),
|
||||
placeholder: '请选择或搜索店铺'
|
||||
},
|
||||
options: () => shopOptions.value.map((shop) => ({ label: shop.shop_name, value: shop.id }))
|
||||
},
|
||||
{
|
||||
label: '套餐',
|
||||
prop: 'package_id',
|
||||
type: 'select',
|
||||
config: {
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
remote: true,
|
||||
loading: packageLoading.value,
|
||||
remoteMethod: (query: string) => searchPackages(query),
|
||||
placeholder: '请选择或搜索套餐'
|
||||
},
|
||||
options: () =>
|
||||
packageOptions.value.map((pkg) => ({
|
||||
label: `${pkg.package_name} (${pkg.package_code})`,
|
||||
value: pkg.id
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: '最小剩余天数',
|
||||
prop: 'days_min',
|
||||
type: 'input',
|
||||
config: { clearable: true, inputmode: 'numeric', placeholder: '最小天数' }
|
||||
},
|
||||
{
|
||||
label: '最大剩余天数',
|
||||
prop: 'days_max',
|
||||
type: 'input',
|
||||
config: { clearable: true, inputmode: 'numeric', placeholder: '最大天数' }
|
||||
},
|
||||
{
|
||||
label: '预计到期起始',
|
||||
prop: 'expires_from',
|
||||
type: 'date',
|
||||
config: { valueFormat: 'YYYY-MM-DD', clearable: true }
|
||||
},
|
||||
{
|
||||
label: '预计到期结束',
|
||||
prop: 'expires_to',
|
||||
type: 'date',
|
||||
config: { valueFormat: 'YYYY-MM-DD', clearable: true }
|
||||
}
|
||||
])
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '资产类型', prop: 'asset_type' },
|
||||
{ label: '资产标识', prop: 'identifier' },
|
||||
{ label: '店铺', prop: 'shop_name' },
|
||||
{ label: '当前套餐', prop: 'package_name' },
|
||||
{ label: '预计最终到期', prop: 'estimated_final_expires_at' },
|
||||
{ label: '剩余天数', prop: 'days_until_final_expiry' },
|
||||
{ label: '临期等级', prop: 'expiry_level_name' }
|
||||
]
|
||||
|
||||
const getExpiryClass = (days: number) => {
|
||||
if (days <= 3) return 'expiry-critical'
|
||||
if (days <= 7) return 'expiry-warning'
|
||||
if (days <= 15) return 'expiry-notice'
|
||||
return ''
|
||||
}
|
||||
|
||||
const getExpiryTagType = (days: number) => {
|
||||
if (days <= 3) return 'danger'
|
||||
if (days <= 7) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const searchShops = async (query = '') => {
|
||||
shopLoading.value = true
|
||||
try {
|
||||
const response = await ShopService.getShops({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
shop_name: query || undefined
|
||||
})
|
||||
if (response.code === 0) shopOptions.value = response.data.items || []
|
||||
} finally {
|
||||
shopLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchPackages = async (query = '') => {
|
||||
packageLoading.value = true
|
||||
try {
|
||||
const response = await PackageManageService.getPackages({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
package_name: query || undefined
|
||||
})
|
||||
if (response.code === 0) packageOptions.value = response.data.items || []
|
||||
} finally {
|
||||
packageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'asset_type',
|
||||
label: '资产类型',
|
||||
width: 100,
|
||||
formatter: (row: ExpiringAssetItem) => (row.asset_type === 'card' ? '网卡' : '设备')
|
||||
},
|
||||
{
|
||||
prop: 'identifier',
|
||||
label: '资产标识',
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: ExpiringAssetItem) => row.identifier || '-'
|
||||
},
|
||||
{
|
||||
prop: 'shop_name',
|
||||
label: '店铺',
|
||||
minWidth: 140,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: ExpiringAssetItem) => row.shop_name || '-'
|
||||
},
|
||||
{
|
||||
prop: 'package_name',
|
||||
label: '当前套餐',
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: ExpiringAssetItem) => row.package_name || '-'
|
||||
},
|
||||
{
|
||||
prop: 'estimated_final_expires_at',
|
||||
label: '预计最终到期',
|
||||
width: 180,
|
||||
formatter: (row: ExpiringAssetItem) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: getExpiryClass(row.days_until_final_expiry) },
|
||||
formatDateTime(row.estimated_final_expires_at)
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'days_until_final_expiry',
|
||||
label: '剩余天数',
|
||||
width: 100,
|
||||
formatter: (row: ExpiringAssetItem) =>
|
||||
h(
|
||||
ElTag,
|
||||
{ type: getExpiryTagType(row.days_until_final_expiry), size: 'small' },
|
||||
() => `${row.days_until_final_expiry}天`
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'expiry_level_name',
|
||||
label: '临期等级',
|
||||
width: 120,
|
||||
formatter: (row: ExpiringAssetItem) => row.expiry_level_name || '-'
|
||||
}
|
||||
])
|
||||
|
||||
const loadAssets = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await AssetService.getExpiringAssets({
|
||||
...searchForm,
|
||||
page: pagination.currentPage,
|
||||
size: pagination.pageSize
|
||||
})
|
||||
if (response.code === 0 && response.data) {
|
||||
items.value = response.data.items || []
|
||||
pagination.total = response.data.total || 0
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.currentPage = 1
|
||||
void loadAssets()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, {
|
||||
asset_type: undefined,
|
||||
keyword: '',
|
||||
shop_id: undefined,
|
||||
package_id: undefined,
|
||||
days_min: undefined,
|
||||
days_max: undefined,
|
||||
expires_from: undefined,
|
||||
expires_to: undefined
|
||||
})
|
||||
pagination.currentPage = 1
|
||||
void loadAssets()
|
||||
}
|
||||
|
||||
const handleRefresh = () => void loadAssets()
|
||||
const handleSizeChange = (value: number) => {
|
||||
pagination.pageSize = value
|
||||
void loadAssets()
|
||||
}
|
||||
const handleCurrentChange = (value: number) => {
|
||||
pagination.currentPage = value
|
||||
void loadAssets()
|
||||
}
|
||||
|
||||
const goToAsset = (row: ExpiringAssetItem) => {
|
||||
router.push({
|
||||
path: RoutesAlias.AssetInformation,
|
||||
query: row.asset_type === 'card' ? { iccid: row.identifier } : { virtual_no: row.identifier }
|
||||
})
|
||||
}
|
||||
|
||||
const getActions = (row: ExpiringAssetItem) => [
|
||||
{ label: '查看资产', type: 'primary' as const, handler: () => goToAsset(row) },
|
||||
...(row.can_renew
|
||||
? [{ label: '续费', type: 'primary' as const, handler: () => goToAsset(row) }]
|
||||
: [])
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
const assetType = route.query.asset_type
|
||||
if (assetType === 'card' || assetType === 'device')
|
||||
searchForm.asset_type = assetType as AssetType
|
||||
void Promise.all([loadAssets(), searchShops(), searchPackages()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.expiring-assets-page {
|
||||
.page-intro {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.expiry-critical,
|
||||
.expiry-warning,
|
||||
.expiry-notice {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expiry-critical {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.expiry-warning {
|
||||
color: #8e44ad;
|
||||
}
|
||||
|
||||
.expiry-notice {
|
||||
color: #e83e8c;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -141,14 +141,14 @@
|
||||
>
|
||||
<ElOption
|
||||
v-for="salesperson in salespersonOptions"
|
||||
:key="salesperson.account_id"
|
||||
:label="salesperson.account_name"
|
||||
:value="salesperson.account_id"
|
||||
:key="salesperson.id"
|
||||
:label="salesperson.username"
|
||||
:value="salesperson.id"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between">
|
||||
<span>{{ salesperson.account_name }}</span>
|
||||
<span>{{ salesperson.username }}</span>
|
||||
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
|
||||
{{ salesperson.phone_masked }}
|
||||
{{ salesperson.phone_summary }}
|
||||
</span>
|
||||
</div>
|
||||
</ElOption>
|
||||
@@ -328,10 +328,14 @@
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { generateShopCode } from '@/utils/codeGenerator'
|
||||
import { ShopService, RoleService, AccountService } from '@/api/modules'
|
||||
import { ShopService, RoleService } from '@/api/modules'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { PlatformSalesperson, ShopResponse, ShopRoleResponse } from '@/types/api'
|
||||
import { AccountStatus, RoleType, RoleStatus } from '@/types/api'
|
||||
import type {
|
||||
ShopBusinessOwnerCandidate,
|
||||
ShopResponse,
|
||||
ShopRoleResponse
|
||||
} from '@/types/api'
|
||||
import { RoleType, RoleStatus } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
|
||||
import { regionData } from '@/utils/constants/regionData'
|
||||
@@ -349,7 +353,7 @@
|
||||
const defaultRoleLoading = ref(false)
|
||||
const defaultRoleList = ref<any[]>([])
|
||||
const salespersonLoading = ref(false)
|
||||
const salespersonOptions = ref<PlatformSalesperson[]>([])
|
||||
const salespersonOptions = ref<ShopBusinessOwnerCandidate[]>([])
|
||||
const detailDialogVisible = ref(false)
|
||||
const detailShop = ref<ShopResponse | null>(null)
|
||||
|
||||
@@ -418,12 +422,10 @@
|
||||
const searchPlatformSalespeople = async (query: string) => {
|
||||
salespersonLoading.value = true
|
||||
try {
|
||||
const res = await AccountService.getPlatformSalespeople({
|
||||
const res = await ShopService.getBusinessOwnerCandidates({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
account_type: 'platform',
|
||||
status: AccountStatus.ENABLED,
|
||||
account_name: query || undefined
|
||||
keyword: query || undefined
|
||||
})
|
||||
if (res.code === 0) {
|
||||
salespersonOptions.value = res.data.items || []
|
||||
@@ -437,14 +439,24 @@
|
||||
}
|
||||
|
||||
const formatBusinessOwner = (shop: ShopResponse) => {
|
||||
if (!shop.business_owner_name) return '-'
|
||||
return shop.business_owner_phone_masked
|
||||
? `${shop.business_owner_name} (${shop.business_owner_phone_masked})`
|
||||
: shop.business_owner_name
|
||||
if (!shop.business_owner_username) return '-'
|
||||
return shop.business_owner_phone_summary
|
||||
? `${shop.business_owner_username} (${shop.business_owner_phone_summary})`
|
||||
: shop.business_owner_username
|
||||
}
|
||||
|
||||
const showDetail = (row: ShopResponse) => {
|
||||
detailShop.value = row
|
||||
const showDetail = async (row: ShopResponse) => {
|
||||
try {
|
||||
const response = await ShopService.getShopDetail(row.id)
|
||||
if (response.code !== 0 || !response.data) {
|
||||
ElMessage.error(response.msg || '获取店铺详情失败')
|
||||
return
|
||||
}
|
||||
detailShop.value = response.data
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '获取店铺详情失败')
|
||||
return
|
||||
}
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -570,8 +582,8 @@
|
||||
},
|
||||
options: () =>
|
||||
salespersonOptions.value.map((salesperson) => ({
|
||||
label: `${salesperson.account_name} (${salesperson.phone_masked})`,
|
||||
value: salesperson.account_id
|
||||
label: `${salesperson.username} (${salesperson.phone_summary})`,
|
||||
value: salesperson.id
|
||||
}))
|
||||
}
|
||||
])
|
||||
@@ -584,7 +596,7 @@
|
||||
{ label: '所在地区', prop: 'region' },
|
||||
{ label: '联系人', prop: 'contact_name' },
|
||||
{ label: '联系电话', prop: 'contact_phone' },
|
||||
{ label: '平台业务员', prop: 'business_owner_name' },
|
||||
{ label: '平台业务员', prop: 'business_owner_username' },
|
||||
...(canModifyShopStatus ? [{ label: '状态', prop: 'status' }] : []),
|
||||
{ label: '创建时间', prop: 'created_at' },
|
||||
{ label: '操作', prop: 'operation' }
|
||||
@@ -717,7 +729,7 @@
|
||||
width: 130
|
||||
},
|
||||
{
|
||||
prop: 'business_owner_name',
|
||||
prop: 'business_owner_username',
|
||||
label: '平台业务员',
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
|
||||
Reference in New Issue
Block a user