From 9289a6e940ab9ef5f3f088772006f9ebb3f73232 Mon Sep 17 00:00:00 2001 From: luo Date: Sat, 25 Jul 2026 10:20:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=8E=A5=E5=8F=A319-?= =?UTF-8?q?=E9=A1=B6=E9=83=A8=E9=80=9A=E7=9F=A5=E9=93=83=E9=93=9B=E4=B8=8E?= =?UTF-8?q?=E7=AB=99=E5=86=85=E9=80=9A=E7=9F=A5=E4=B8=AD=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../design.md | 35 ++ .../proposal.md | 35 ++ .../specs/admin-expiring-assets/spec.md | 111 ++++++ .../tasks.md | 35 ++ .../design.md | 36 ++ .../proposal.md | 40 ++ .../specs/notification-center/spec.md | 84 ++++ .../tasks.md | 34 ++ .../proposal.md | 5 +- .../specs/shop-management/spec.md | 11 +- .../tasks.md | 2 +- src/api/modules/account.ts | 12 - src/api/modules/asset.ts | 17 +- src/api/modules/notification.ts | 39 +- src/api/modules/shop.ts | 25 +- .../core/layouts/art-header-bar/index.vue | 4 +- .../core/layouts/art-notification/index.vue | 22 +- .../core/layouts/art-notification/style.scss | 6 +- src/router/routes/asyncRoutes.ts | 26 +- src/router/routesAlias.ts | 1 + src/store/modules/notification.ts | 73 ++-- src/types/api/account.ts | 14 - src/types/api/asset.ts | 29 ++ src/types/api/card.ts | 3 + src/types/api/device.ts | 3 + src/types/api/notification.ts | 76 ++-- src/types/api/shop.ts | 19 +- src/utils/business/expiryEstimate.ts | 8 + src/utils/business/notificationNavigation.ts | 40 +- .../components/BasicInfoCard.vue | 25 +- .../asset-management/device-list/index.vue | 23 +- .../iot-card-management/index.vue | 23 +- src/views/dashboard/console/index.vue | 10 +- .../console/widget/ExpiringAssetStats.vue | 102 +++++ src/views/notifications/index.vue | 28 +- .../operations/expiring-assets/index.vue | 364 ++++++++++++++++++ src/views/shop-management/list/index.vue | 70 ++-- 37 files changed, 1264 insertions(+), 226 deletions(-) create mode 100644 openspec/changes/add-admin-expiring-assets-notifications/design.md create mode 100644 openspec/changes/add-admin-expiring-assets-notifications/proposal.md create mode 100644 openspec/changes/add-admin-expiring-assets-notifications/specs/admin-expiring-assets/spec.md create mode 100644 openspec/changes/add-admin-expiring-assets-notifications/tasks.md create mode 100644 openspec/changes/update-admin-notification-center-api/design.md create mode 100644 openspec/changes/update-admin-notification-center-api/proposal.md create mode 100644 openspec/changes/update-admin-notification-center-api/specs/notification-center/spec.md create mode 100644 openspec/changes/update-admin-notification-center-api/tasks.md create mode 100644 src/views/dashboard/console/widget/ExpiringAssetStats.vue create mode 100644 src/views/operations/expiring-assets/index.vue diff --git a/openspec/changes/add-admin-expiring-assets-notifications/design.md b/openspec/changes/add-admin-expiring-assets-notifications/design.md new file mode 100644 index 0000000..0b9e3e9 --- /dev/null +++ b/openspec/changes/add-admin-expiring-assets-notifications/design.md @@ -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 + +- 代理首页现有概览接口的具体路径和响应字段名称,需要以后端接口实现为准确认。 +- 管理端卡/设备续费入口的现有路由是否统一,需实现时复用当前权限和路由定义。 diff --git a/openspec/changes/add-admin-expiring-assets-notifications/proposal.md b/openspec/changes/add-admin-expiring-assets-notifications/proposal.md new file mode 100644 index 0000000..ac19a2e --- /dev/null +++ b/openspec/changes/add-admin-expiring-assets-notifications/proposal.md @@ -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 端续费流程 + - 企微临期消息展示或企微通知改造 + - 前端根据套餐明细自行推导预计最终到期时间 diff --git a/openspec/changes/add-admin-expiring-assets-notifications/specs/admin-expiring-assets/spec.md b/openspec/changes/add-admin-expiring-assets-notifications/specs/admin-expiring-assets/spec.md new file mode 100644 index 0000000..14bc997 --- /dev/null +++ b/openspec/changes/add-admin-expiring-assets-notifications/specs/admin-expiring-assets/spec.md @@ -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 diff --git a/openspec/changes/add-admin-expiring-assets-notifications/tasks.md b/openspec/changes/add-admin-expiring-assets-notifications/tasks.md new file mode 100644 index 0000000..17ef23a --- /dev/null +++ b/openspec/changes/add-admin-expiring-assets-notifications/tasks.md @@ -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`。 diff --git a/openspec/changes/update-admin-notification-center-api/design.md b/openspec/changes/update-admin-notification-center-api/design.md new file mode 100644 index 0000000..3de5826 --- /dev/null +++ b/openspec/changes/update-admin-notification-center-api/design.md @@ -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` 的完整白名单值需要以后端实际返回样例补充;未确认值统一按不可跳转处理。 diff --git a/openspec/changes/update-admin-notification-center-api/proposal.md b/openspec/changes/update-admin-notification-center-api/proposal.md new file mode 100644 index 0000000..e6e4fac --- /dev/null +++ b/openspec/changes/update-admin-notification-center-api/proposal.md @@ -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、浏览器推送和新的实时推送机制 diff --git a/openspec/changes/update-admin-notification-center-api/specs/notification-center/spec.md b/openspec/changes/update-admin-notification-center-api/specs/notification-center/spec.md new file mode 100644 index 0000000..4c4a506 --- /dev/null +++ b/openspec/changes/update-admin-notification-center-api/specs/notification-center/spec.md @@ -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 diff --git a/openspec/changes/update-admin-notification-center-api/tasks.md b/openspec/changes/update-admin-notification-center-api/tasks.md new file mode 100644 index 0000000..a47fb2d --- /dev/null +++ b/openspec/changes/update-admin-notification-center-api/tasks.md @@ -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`。 diff --git a/openspec/changes/update-shop-salesperson-selection/proposal.md b/openspec/changes/update-shop-salesperson-selection/proposal.md index a6d058e..acee413 100644 --- a/openspec/changes/update-shop-salesperson-selection/proposal.md +++ b/openspec/changes/update-shop-salesperson-selection/proposal.md @@ -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` diff --git a/openspec/changes/update-shop-salesperson-selection/specs/shop-management/spec.md b/openspec/changes/update-shop-salesperson-selection/specs/shop-management/spec.md index 33ef119..4d780f4 100644 --- a/openspec/changes/update-shop-salesperson-selection/specs/shop-management/spec.md +++ b/openspec/changes/update-shop-salesperson-selection/specs/shop-management/spec.md @@ -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 diff --git a/openspec/changes/update-shop-salesperson-selection/tasks.md b/openspec/changes/update-shop-salesperson-selection/tasks.md index 802e324..15f1c30 100644 --- a/openspec/changes/update-shop-salesperson-selection/tasks.md +++ b/openspec/changes/update-shop-salesperson-selection/tasks.md @@ -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 diff --git a/src/api/modules/account.ts b/src/api/modules/account.ts index f3256f3..8b8a39f 100644 --- a/src/api/modules/account.ts +++ b/src/api/modules/account.ts @@ -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('/api/admin/accounts', params) } - /** - * 获取启用的平台业务员候选 - * GET /api/admin/accounts?account_type=platform&status=1 - */ - static getPlatformSalespeople( - params: PlatformSalespersonQueryParams - ): Promise> { - return this.getPage('/api/admin/accounts', params) - } - /** * 创建账号 * POST /api/admin/accounts diff --git a/src/api/modules/asset.ts b/src/api/modules/asset.ts index 12c98a9..88a19c7 100644 --- a/src/api/modules/asset.ts +++ b/src/api/modules/asset.ts @@ -31,7 +31,9 @@ import type { AssetOperationLogsParams, AssetPackageUsageRecord, UpdateAssetPackageUsedDataRequest, - UpdateAssetPackageExpiresAtRequest + UpdateAssetPackageExpiresAtRequest, + ExpiringAssetItem, + ExpiringAssetQueryParams } from '@/types/api' const runRateLimitedAssetAction = async ( @@ -45,6 +47,19 @@ const runRateLimitedAssetAction = async ( } 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 diff --git a/src/api/modules/notification.ts b/src/api/modules/notification.ts index 4f95965..da75f5f 100644 --- a/src/api/modules/notification.ts +++ b/src/api/modules/notification.ts @@ -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 - > { - return this.get>( - '/api/admin/notifications/unread-count' - ) + static getUnreadCount(): Promise> { + return this.get>('/api/admin/notifications/unread-count') } - static getUnreadSummary(): Promise> { - return this.get>('/api/admin/notifications/unread-summary') + static getUnreadSummary(): Promise> { + return this.get>( + '/api/admin/notifications/unread-summary' + ) } static getNotifications( @@ -27,15 +28,23 @@ export class NotificationService extends BaseService { return this.get>('/api/admin/notifications', params) } - static markRead(id: number | string): Promise> { - return this.put>(`/api/admin/notifications/${id}/read`, {}) + static markRead(id: number): Promise> { + return this.put>( + `/api/admin/notifications/${id}/read`, + {} + ) } - static markAllRead(): Promise { - return this.put('/api/admin/notifications/read-all', {}) + static markAllRead( + data: NotificationReadAllRequest = {} + ): Promise> { + return this.put>( + '/api/admin/notifications/read-all', + data + ) } - static getTarget(id: number | string): Promise> { + static getTarget(id: number): Promise> { return this.get>(`/api/admin/notifications/${id}/target`) } } diff --git a/src/api/modules/shop.ts b/src/api/modules/shop.ts index 9ee79ef..3888c21 100644 --- a/src/api/modules/shop.ts +++ b/src/api/modules/shop.ts @@ -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('/api/admin/shops', params) } + /** + * 获取店铺业务员候选 + * GET /api/admin/shops/business-owner-candidates + */ + static getBusinessOwnerCandidates( + params?: ShopBusinessOwnerCandidateQueryParams + ): Promise> { + return this.getPage( + '/api/admin/shops/business-owner-candidates', + params + ) + } + + /** + * 获取店铺详情 + * GET /api/admin/shops/{id} + */ + static getShopDetail(id: number): Promise> { + return this.getOne(`/api/admin/shops/${id}`) + } + /** * 店铺级联查询(树结构) * GET /api/admin/shops/cascade diff --git a/src/components/core/layouts/art-header-bar/index.vue b/src/components/core/layouts/art-header-bar/index.vue index ce9059f..5d35a97 100644 --- a/src/components/core/layouts/art-header-bar/index.vue +++ b/src/components/core/layouts/art-header-bar/index.vue @@ -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 diff --git a/src/components/core/layouts/art-notification/index.vue b/src/components/core/layouts/art-notification/index.vue index 78d43fc..6f6d807 100644 --- a/src/components/core/layouts/art-notification/index.vue +++ b/src/components/core/layouts/art-notification/index.vue @@ -39,7 +39,7 @@ {{ item.title }} - {{ item.content }} + {{ item.body }} @@ -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() } diff --git a/src/components/core/layouts/art-notification/style.scss b/src/components/core/layouts/art-notification/style.scss index 39f556f..a2e0b7a 100644 --- a/src/components/core/layouts/art-notification/style.scss +++ b/src/components/core/layouts/art-notification/style.scss @@ -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); } } diff --git a/src/router/routes/asyncRoutes.ts b/src/router/routes/asyncRoutes.ts index d022275..5b6db3a 100644 --- a/src/router/routes/asyncRoutes.ts +++ b/src/router/routes/asyncRoutes.ts @@ -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 } } diff --git a/src/router/routesAlias.ts b/src/router/routesAlias.ts index 8984091..1ae80ab 100644 --- a/src/router/routesAlias.ts +++ b/src/router/routesAlias.ts @@ -78,6 +78,7 @@ export enum RoutesAlias { // 站内通知 Notifications = '/notifications', // 通知中心 + ExpiringAssets = '/operations/expiring-assets', // 临期资产 // 佣金管理 WithdrawalApproval = '/commission-management/withdrawal-approval', // 提现审批 diff --git a/src/store/modules/notification.ts b/src/store/modules/notification.ts index 5448700..564fe27 100644 --- a/src/store/modules/notification.ts +++ b/src/store/modules/notification.ts @@ -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({ items: [], categories: {}, total_unread: 0 }) + const displayCount = ref('0') + const summary = ref(emptySummary()) + const recentNotifications = ref([]) 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, diff --git a/src/types/api/account.ts b/src/types/api/account.ts index 94747b4..108e1a7 100644 --- a/src/types/api/account.ts +++ b/src/types/api/account.ts @@ -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 diff --git a/src/types/api/asset.ts b/src/types/api/asset.ts index 3914989..adcd028 100644 --- a/src/types/api/asset.ts +++ b/src/types/api/asset.ts @@ -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[] diff --git a/src/types/api/card.ts b/src/types/api/card.ts index 16ee784..e282a94 100644 --- a/src/types/api/card.ts +++ b/src/types/api/card.ts @@ -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 // 最后实名检查时间 diff --git a/src/types/api/device.ts b/src/types/api/device.ts index 697fe4c..6d2f941 100644 --- a/src/types/api/device.ts +++ b/src/types/api/device.ts @@ -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 // 当前有效授权企业名称 } diff --git a/src/types/api/notification.ts b/src/types/api/notification.ts index 6333c4c..5982311 100644 --- a/src/types/api/notification.ts +++ b/src/types/api/notification.ts @@ -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 - 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 | null - query?: Record | null - message?: string | null + available: boolean + target_id: number | null + target_key: string + target_type: string } diff --git a/src/types/api/shop.ts b/src/types/api/shop.ts index 1b03de8..bca6440 100644 --- a/src/types/api/shop.ts +++ b/src/types/api/shop.ts @@ -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 // 店铺列表 diff --git a/src/utils/business/expiryEstimate.ts b/src/utils/business/expiryEstimate.ts index 5e9560c..1ad6239 100644 --- a/src/utils/business/expiryEstimate.ts +++ b/src/utils/business/expiryEstimate.ts @@ -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 '' +} diff --git a/src/utils/business/notificationNavigation.ts b/src/utils/business/notificationNavigation.ts index 758854f..3d60084 100644 --- a/src/utils/business/notificationNavigation.ts +++ b/src/utils/business/notificationNavigation.ts @@ -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 +} - 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 } diff --git a/src/views/asset-management/asset-information/components/BasicInfoCard.vue b/src/views/asset-management/asset-information/components/BasicInfoCard.vue index 2803f76..e5ec523 100644 --- a/src/views/asset-management/asset-information/components/BasicInfoCard.vue +++ b/src/views/asset-management/asset-information/components/BasicInfoCard.vue @@ -88,7 +88,7 @@ {{ getExpiryEstimateText(cardInfo) }} @@ -182,7 +182,7 @@ {{ 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; diff --git a/src/views/asset-management/device-list/index.vue b/src/views/asset-management/device-list/index.vue index 076c6de..6e27391 100644 --- a/src/views/asset-management/device-list/index.vue +++ b/src/views/asset-management/device-list/index.vue @@ -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; + } diff --git a/src/views/asset-management/iot-card-management/index.vue b/src/views/asset-management/iot-card-management/index.vue index 60ac789..a10d804 100644 --- a/src/views/asset-management/iot-card-management/index.vue +++ b/src/views/asset-management/iot-card-management/index.vue @@ -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; + } diff --git a/src/views/dashboard/console/index.vue b/src/views/dashboard/console/index.vue index d9d44a8..8b60fbe 100644 --- a/src/views/dashboard/console/index.vue +++ b/src/views/dashboard/console/index.vue @@ -1,6 +1,6 @@ + + diff --git a/src/views/notifications/index.vue b/src/views/notifications/index.vue index 36d8034..5ff6705 100644 --- a/src/views/notifications/index.vue +++ b/src/views/notifications/index.vue @@ -20,10 +20,10 @@
- - + + @@ -32,11 +32,11 @@ - + - - - + + + 刷新
@@ -59,7 +59,8 @@ - + +