From 08889d2e42113c3098abba176427a2e0a4f27db9 Mon Sep 17 00:00:00 2001 From: luo Date: Fri, 18 Sep 2026 18:11:53 +0800 Subject: [PATCH] fix: some --- .../design.md | 38 ++ .../proposal.md | 27 + .../specs/operations-reports/spec.md | 57 ++ .../specs/time-filter-conventions/spec.md | 34 ++ .../tasks.md | 35 ++ .../proposal.md | 23 + .../specs/export-task-management/spec.md | 37 ++ .../specs/operations-reports/spec.md | 31 ++ .../tasks.md | 24 + .../design.md | 41 ++ .../proposal.md | 23 + .../specs/commission-management/spec.md | 28 + .../specs/employee-collection/spec.md | 64 +++ .../tasks.md | 23 + src/api/modules/asset.ts | 16 +- src/api/modules/exchange.ts | 4 +- src/api/modules/index.ts | 2 + src/api/modules/operationsReports.ts | 65 +++ src/components/business/VoucherUpload.vue | 39 +- src/config/constants/exportTask.ts | 35 +- src/locales/langs/en.json | 3 + src/locales/langs/zh.json | 7 +- src/router/routes/asyncRoutes.ts | 63 +++ src/router/routesAlias.ts | 11 +- src/types/api/agentRecharge.ts | 4 +- src/types/api/asset.ts | 4 +- src/types/api/card.ts | 4 +- src/types/api/exportTask.ts | 3 + src/types/api/index.ts | 3 + src/types/api/operationsReport.ts | 131 +++++ src/types/components.d.ts | 1 + .../exchange-management/index.vue | 28 +- .../expiring-assets/index.vue | 159 ++++-- .../export-expiring-asset/index.vue | 9 + .../export-operations-activation/index.vue | 9 + .../export-operations-renewal/index.vue | 9 + .../phone-asset-association/index.vue | 2 +- .../unbind-import-tasks/index.vue | 2 +- .../record-management/asset-assign/index.vue | 12 +- .../my-commission/index.vue | 516 +++++++++++------- src/views/dashboard/analysis/index.vue | 6 +- .../widget/OperationsActivationTrend.vue | 180 ++++++ .../widget/OperationsRenewalTrend.vue | 180 ++++++ src/views/finance/agent-recharge/index.vue | 20 +- .../components/ApplicationFormDialog.vue | 474 +++++++++++++--- .../employee-collection/bills/index.vue | 11 +- .../operations-reports/activation/index.vue | 277 ++++++++++ .../components/OperationsTrendChart.vue | 115 ++++ src/views/operations-reports/constants.ts | 96 ++++ .../operations-reports/renewal/index.vue | 277 ++++++++++ .../traffic-alert-rules/index.vue | 2 +- .../traffic-alerts/index.vue | 2 +- .../settings/h5-popup-configuration/index.vue | 2 +- .../components/MerchantManagement.vue | 12 +- src/views/shop-management/list/index.vue | 22 +- 55 files changed, 2888 insertions(+), 414 deletions(-) create mode 100644 openspec/changes/add-operations-reports-and-time-filters/design.md create mode 100644 openspec/changes/add-operations-reports-and-time-filters/proposal.md create mode 100644 openspec/changes/add-operations-reports-and-time-filters/specs/operations-reports/spec.md create mode 100644 openspec/changes/add-operations-reports-and-time-filters/specs/time-filter-conventions/spec.md create mode 100644 openspec/changes/add-operations-reports-and-time-filters/tasks.md create mode 100644 openspec/changes/update-operations-reports-trend-and-export-scenes/proposal.md create mode 100644 openspec/changes/update-operations-reports-trend-and-export-scenes/specs/export-task-management/spec.md create mode 100644 openspec/changes/update-operations-reports-trend-and-export-scenes/specs/operations-reports/spec.md create mode 100644 openspec/changes/update-operations-reports-trend-and-export-scenes/tasks.md create mode 100644 openspec/changes/update-writeoff-application-and-superadmin-commission/design.md create mode 100644 openspec/changes/update-writeoff-application-and-superadmin-commission/proposal.md create mode 100644 openspec/changes/update-writeoff-application-and-superadmin-commission/specs/commission-management/spec.md create mode 100644 openspec/changes/update-writeoff-application-and-superadmin-commission/specs/employee-collection/spec.md create mode 100644 openspec/changes/update-writeoff-application-and-superadmin-commission/tasks.md create mode 100644 src/api/modules/operationsReports.ts create mode 100644 src/types/api/operationsReport.ts create mode 100644 src/views/asset-management/export-task-management/export-expiring-asset/index.vue create mode 100644 src/views/asset-management/export-task-management/export-operations-activation/index.vue create mode 100644 src/views/asset-management/export-task-management/export-operations-renewal/index.vue create mode 100644 src/views/dashboard/analysis/widget/OperationsActivationTrend.vue create mode 100644 src/views/dashboard/analysis/widget/OperationsRenewalTrend.vue create mode 100644 src/views/operations-reports/activation/index.vue create mode 100644 src/views/operations-reports/components/OperationsTrendChart.vue create mode 100644 src/views/operations-reports/constants.ts create mode 100644 src/views/operations-reports/renewal/index.vue diff --git a/openspec/changes/add-operations-reports-and-time-filters/design.md b/openspec/changes/add-operations-reports-and-time-filters/design.md new file mode 100644 index 0000000..674a854 --- /dev/null +++ b/openspec/changes/add-operations-reports-and-time-filters/design.md @@ -0,0 +1,38 @@ +# Design: 新增运营报表模块并迁移时间筛选参数 + +## Context + +- 接口文档 `docs/产品迭代8月份/前端接口简版汇总.md` 只给出指标中文名,未给出 JSON 字段名;`默认模块.openapi.json` 不包含 `operations-reports`。 +- 项目已有 ECharts 封装(`useChart` / `ArtLineChart` / `ArtBarChart`)与导出任务轮询(`useAsyncTaskPolling` + `ExportTaskService`),应复用而非新建。 +- 其余列表的时间参数迁移属于参数重命名,不改变页面交互。 + +## Goals / Non-Goals + +- Goals: 报表页可用、参数迁移后筛选生效、导出复用既有异步任务链路。 +- Non-Goals: 不改动后端、不新增后端字段定义;不重构既有导出任务中心。 + +## Decisions + +### 指标字段以「防御性可选类型 + 集中列配置」实现 + +由于接口未提供字段名,`operationsReport.ts` 的指标字段统一声明为可选,并保留 `[key: string]: unknown` 索引签名;所有列定义集中在 `src/views/operations-reports/constants.ts`。后端字段名如与推断不同,只需改一处常量即可,不改模板与请求逻辑。 + +- 比率/卡均类指标:`number | null`,`null` 渲染为 `-`,否则固定两位小数。 +- 汇总响应的 `totals` 用于「全部」分组与合计行;`has_snapshot=false` 时展示空态。 + +### 趋势图使用项目既有 ECharts 体系 + +`OperationsTrendChart.vue` 基于 `useChart` 初始化,props 接收 `categories` 与多 `series`(含 `type`/`yAxisIndex` 以支持比率走次坐标轴),`watch` 深度监听后 `setOption`,卸载时 dispose。 + +### 导出复用异步任务轮询 + +导出接口仅返回任务信息(`task_id/task_no/status/...`)。页面调用 `useAsyncTaskPolling`(`fetchTask` = `ExportTaskService.getExportTaskDetail`)轮询,终态后提供下载;403 透传为无权限提示。每个页面使用独立 `storageKey` 避免互相串扰。 + +### 路由与访问控制 + +在 `asyncRoutes` 新增顶层 `/operations-reports`(`component: RoutesAlias.Home`)与两个子路由;`meta.allowedUserTypes` 限制为超管/平台(沿用现有权限门禁机制)。菜单标题使用中文字面量,与现有静态业务页保持一致。 + +## Risks / Trade-offs + +- 指标字段名未经后端确认 → 通过集中常量降低风险,联调时单点修正。 +- 报表页可能返回大量行 → 首版不做虚拟滚动,依赖后端分页/汇总;后续按需优化。 diff --git a/openspec/changes/add-operations-reports-and-time-filters/proposal.md b/openspec/changes/add-operations-reports-and-time-filters/proposal.md new file mode 100644 index 0000000..b82542f --- /dev/null +++ b/openspec/changes/add-operations-reports-and-time-filters/proposal.md @@ -0,0 +1,27 @@ +# Change: 新增运营报表模块并迁移时间筛选参数 + +## Why + +后端新增「运营报表」接口(设备激活汇总/趋势/导出、套餐续费汇总/趋势/导出),前端目前既没有对应 API 封装也没有任何页面。同时,多个列表接口的时间筛选参数统一变更为 `start_time`/`end_time`(RFC3339 带时区、闭区间),旧参数(`created_at_start/end`、`expires_from/to`、`start_date/end_date`)将返回 `1001`,前端必须同步迁移,否则相关页面筛选会失效。 + +## What Changes + +- 新增 `OperationsReportsService` API 模块及对应类型定义,封装 4 个查询接口与 2 个导出接口。 +- 新增顶层菜单「运营报表」,含「设备激活报表」「套餐续费报表」两个子页;每页提供时间/分组筛选、汇总表格、趋势图与异步导出。 +- 运营报表仅超级管理员/平台账号可访问,其他账号返回 `403`。 +- 换货单列表、资产分配记录、代理充值订单、临期资产列表时间筛选参数迁移为 `start_time`/`end_time`。 +- 新增临期资产导出接口 `POST /api/admin/expiring-assets/export`(复用列表筛选参数 + `format: 'xlsx'`,异步任务)。 +- 汇总比率、卡均保留两位小数,分母为零或无快照时显示 `-`;无快照时展示空态。 + +## Impact + +- Affected specs: `operations-reports`(新增)、`time-filter-conventions`(新增) +- Affected code: + - `src/api/modules/operationsReports.ts`(新增)、`src/api/modules/index.ts` + - `src/types/api/operationsReport.ts`(新增)、`src/types/api/index.ts` + - `src/views/operations-reports/activation/index.vue`、`src/views/operations-reports/renewal/index.vue`、`src/views/operations-reports/components/OperationsTrendChart.vue`、`src/views/operations-reports/constants.ts`(新增) + - `src/router/routesAlias.ts`、`src/router/routes/asyncRoutes.ts` + - `src/api/modules/exchange.ts`、`src/views/asset-management/exchange-management/index.vue` + - `src/types/api/card.ts`、`src/views/asset-management/record-management/asset-assign/index.vue` + - `src/types/api/agentRecharge.ts`、`src/views/finance/agent-recharge/index.vue` + - `src/types/api/asset.ts`、`src/api/modules/asset.ts`、`src/views/asset-management/expiring-assets/index.vue` diff --git a/openspec/changes/add-operations-reports-and-time-filters/specs/operations-reports/spec.md b/openspec/changes/add-operations-reports-and-time-filters/specs/operations-reports/spec.md new file mode 100644 index 0000000..0d1687b --- /dev/null +++ b/openspec/changes/add-operations-reports-and-time-filters/specs/operations-reports/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: 运营报表 API 服务 + +系统 SHALL 提供 `OperationsReportsService` 封装运营报表 6 个接口: + +- 查询设备激活汇总 `GET /api/admin/operations-reports/activation-summary` +- 查询设备激活趋势 `GET /api/admin/operations-reports/activation-trend` +- 导出设备激活汇总 `POST /api/admin/operations-reports/activation-summary/export` +- 查询套餐续费汇总 `GET /api/admin/operations-reports/package-renewal-summary` +- 查询套餐续费趋势 `GET /api/admin/operations-reports/package-renewal-trend` +- 导出套餐续费汇总 `POST /api/admin/operations-reports/package-renewal-summary/export` + +查询参数 SHALL 使用 `start_time`/`end_time`(RFC3339 秒级带时区)与 `group_by`;趋势查询额外支持 `granularity`(`day|month`,默认 `day`)。导出请求 SHALL 携带 `format`(`xlsx`)。 + +#### Scenario: 查询设备激活汇总 + +- **WHEN** 调用 `OperationsReportsService.getActivationSummary({ start_time, end_time, group_by })` +- **THEN** 返回包含 `group_by`、`group_name`、`has_snapshot`、`snapshot_dates`、`items`、`totals` 的响应 + +#### Scenario: 导出设备激活汇总 + +- **WHEN** 调用 `OperationsReportsService.exportActivationSummary({ format: 'xlsx', start_time, end_time, group_by })` +- **THEN** 返回包含 `task_id`、`task_no`、`status`、`status_name`、`message` 的异步任务信息 + +### Requirement: 运营报表页面 + +系统 SHALL 提供顶层菜单「运营报表」及两个子页「设备激活报表」「套餐续费报表」。每页 SHALL 包含时间范围与分组筛选、汇总表格、趋势图和导出按钮。 + +#### Scenario: 查询汇总并渲染表格 + +- **WHEN** 用户选择时间范围与分组后点击查询 +- **THEN** 请求对应汇总接口并以表格展示各分组指标与合计行 + +#### Scenario: 渲染趋势图 + +- **WHEN** 汇总数据加载成功 +- **THEN** 趋势图按 `granularity`(日/月)以时间点为横轴展示关键指标曲线 + +#### Scenario: 导出报表 + +- **WHEN** 用户点击导出并成功创建异步任务 +- **THEN** 前端轮询任务状态,任务完成后可下载文件 + +### Requirement: 运营报表访问控制与空态 + +运营报表 SHALL 仅对超级管理员/平台账号开放,其他账号访问 SHALL 返回 `403`。比率与卡均指标 SHALL 保留两位小数;分母为零时返回 `null`,页面显示 `-`。无快照时 `has_snapshot=false`、`totals=null`、`items=[]`,页面 SHALL 展示空态而非报错。 + +#### Scenario: 无快照 + +- **WHEN** 汇总响应 `has_snapshot` 为 `false` +- **THEN** 页面展示空态 + +#### Scenario: 比率分母为零 + +- **WHEN** `activation_rate` 或 `renewal_rate` 为 `null` +- **THEN** 表格对应单元格显示 `-` diff --git a/openspec/changes/add-operations-reports-and-time-filters/specs/time-filter-conventions/spec.md b/openspec/changes/add-operations-reports-and-time-filters/specs/time-filter-conventions/spec.md new file mode 100644 index 0000000..addefce --- /dev/null +++ b/openspec/changes/add-operations-reports-and-time-filters/specs/time-filter-conventions/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: 列表时间筛选参数统一 + +以下列表接口的时间筛选参数 SHALL 统一为 `start_time`/`end_time`(RFC3339 秒级、闭区间),前端不得再发送旧参数: + +- 换货单列表 `GET /api/admin/exchanges`(由 `created_at_start`/`created_at_end` 迁移) +- 资产分配记录 `GET /api/admin/asset-allocation-records`(由 `created_at_start`/`created_at_end` 迁移) +- 代理充值订单 `GET /api/admin/agent-recharges`(由 `start_date`/`end_date` 迁移,取消按日自动补齐首尾秒) +- 临期资产列表 `GET /api/admin/expiring-assets`(由 `expires_from`/`expires_to` 迁移) + +#### Scenario: 换货单列表时间筛选 + +- **WHEN** 用户选择创建时间范围并搜索 +- **THEN** 请求参数为 `start_time`/`end_time`,且不包含 `created_at_start`/`created_at_end` + +#### Scenario: 代理充值订单时间筛选 + +- **WHEN** 用户选择时间范围并搜索或导出 +- **THEN** 请求参数为 `start_time`/`end_time`,且不包含 `start_date`/`end_date` + +#### Scenario: 临期资产列表时间筛选 + +- **WHEN** 用户选择预计到期时间范围并搜索 +- **THEN** 请求参数为 `start_time`/`end_time`,且不包含 `expires_from`/`expires_to` + +### Requirement: 临期资产导出 + +系统 SHALL 提供 `POST /api/admin/expiring-assets/export`,复用临期资产列表筛选参数并携带 `format: 'xlsx'`,返回异步导出任务信息;前端 SHALL 通过现有导出任务接口轮询并在完成后下载。 + +#### Scenario: 导出临期资产 + +- **WHEN** 用户在临期资产页面点击导出 +- **THEN** 创建异步导出任务并轮询状态,完成后可下载 xlsx 文件 diff --git a/openspec/changes/add-operations-reports-and-time-filters/tasks.md b/openspec/changes/add-operations-reports-and-time-filters/tasks.md new file mode 100644 index 0000000..7342daa --- /dev/null +++ b/openspec/changes/add-operations-reports-and-time-filters/tasks.md @@ -0,0 +1,35 @@ +## 1. 运营报表 API 与类型 + +- [x] 1.1 新增 `src/types/api/operationsReport.ts`(查询、汇总、趋势、导出任务类型) +- [x] 1.2 在 `src/types/api/index.ts` 导出新类型 +- [x] 1.3 新增 `src/api/modules/operationsReports.ts`(6 个接口方法) +- [x] 1.4 在 `src/api/modules/index.ts` 注册 `OperationsReportsService` + +## 2. 运营报表页面 + +- [x] 2.1 新增 `src/views/operations-reports/constants.ts`(分组选项与指标列配置) +- [x] 2.2 新增 `src/views/operations-reports/components/OperationsTrendChart.vue`(多系列趋势图) +- [x] 2.3 新增「设备激活报表」页面(筛选 + 汇总表格 + 趋势图 + 导出) +- [x] 2.4 新增「套餐续费报表」页面(筛选 + 汇总表格 + 趋势图 + 导出) + +## 3. 路由与菜单 + +- [x] 3.1 `routesAlias.ts` 新增两个页面别名 +- [x] 3.2 `asyncRoutes.ts` 新增顶层「运营报表」菜单(含权限限制) + +## 4. 时间筛选参数迁移 + +- [x] 4.1 换货单列表 `created_at_start/end` -> `start_time/end_time`(类型 + 页面) +- [x] 4.2 资产分配记录 `created_at_start/end` -> `start_time/end_time`(类型 + 页面) +- [x] 4.3 代理充值订单 `start_date/end_date` -> `start_time/end_time`(类型 + 页面,含导出参数) +- [x] 4.4 临期资产列表 `expires_from/to` -> `start_time/end_time`(类型 + 页面) + +## 5. 临期资产导出 + +- [x] 5.1 `AssetService.exportExpiringAssets`(POST /api/admin/expiring-assets/export) +- [x] 5.2 临期资产页面新增「导出」按钮(筛选参数 + `format: 'xlsx'` + 异步任务轮询下载) + +## 6. 验证 + +- [x] 6.1 运行 `eslint`、`stylelint`、`vue-tsc --noEmit`、`vite build --mode production` 均通过 +- [x] 6.2 运行 `openspec validate add-operations-reports-and-time-filters --strict` 通过 diff --git a/openspec/changes/update-operations-reports-trend-and-export-scenes/proposal.md b/openspec/changes/update-operations-reports-trend-and-export-scenes/proposal.md new file mode 100644 index 0000000..12fb191 --- /dev/null +++ b/openspec/changes/update-operations-reports-trend-and-export-scenes/proposal.md @@ -0,0 +1,23 @@ +# Change: 调整运营报表展示并补齐导出场景 + +## Why + +运营报表首版将「汇总数据」标题与趋势图放在报表页内,信息层级冗余且与仪表台分析页重复;同时导出管理缺少临期资产、设备激活情况报表、套餐续费情况报表三个导出场景,且佣金/套餐真流量场景命名与实际业务口径不一致。 + +## What Changes + +- 运营报表两个页面移除「汇总数据」标题,仅保留数据快照、导出状态与导出/下载操作。 +- 运营报表两个页面移除趋势图卡片与「统计粒度」筛选,不再请求趋势接口。 +- 在仪表台分析页(`/dashboard/analysis`)新增「设备激活趋势」「套餐续费趋势」组件,占位与门禁与运营报表一致(超级管理员/平台账号);统计粒度改为下拉选择(按日/按月),并支持时间范围与刷新。 +- 导出管理新增 3 个导出场景:`expiring_asset`(临期资产)、`operations_activation`(设备激活情况报表)、`operations_renewal`(套餐续费情况报表)。 +- 修正场景显示名称:`commission_record` → 佣金明细、`package_traffic_alert` → 套餐真流量达量预警。 + +## Impact + +- Affected specs: `operations-reports`(修改)、`export-task-management`(新增/修改) +- Affected code: + - `src/views/operations-reports/activation/index.vue`、`src/views/operations-reports/renewal/index.vue` + - `src/views/dashboard/analysis/index.vue`、`src/views/dashboard/analysis/widget/OperationsActivationTrend.vue`、`src/views/dashboard/analysis/widget/OperationsRenewalTrend.vue`(新增) + - `src/types/api/exportTask.ts`、`src/config/constants/exportTask.ts` + - `src/router/routesAlias.ts`、`src/router/routes/asyncRoutes.ts` + - `src/locales/langs/zh.json`、`src/locales/langs/en.json` diff --git a/openspec/changes/update-operations-reports-trend-and-export-scenes/specs/export-task-management/spec.md b/openspec/changes/update-operations-reports-trend-and-export-scenes/specs/export-task-management/spec.md new file mode 100644 index 0000000..dc887b3 --- /dev/null +++ b/openspec/changes/update-operations-reports-trend-and-export-scenes/specs/export-task-management/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: Additional Export Task Scenes + +The admin frontend SHALL support three additional export task scenes: `expiring_asset`(临期资产)、`operations_activation`(设备激活情况报表)、`operations_renewal`(套餐续费情况报表)。每个场景 SHALL 有独立的导出任务列表子页,页面固定查询该场景任务(`GET /api/admin/export-tasks` with `scene=`),并提供 `export_task::detail` / `export_task::download` 权限配置。 + +#### Scenario: Open expiring asset export task page + +- **GIVEN** 用户打开「导出临期资产」页面 +- **WHEN** 页面查询导出任务列表 +- **THEN** 系统 MUST call `GET /api/admin/export-tasks` with `scene=expiring_asset` + +#### Scenario: Open operations report export task pages + +- **GIVEN** 用户打开「导出设备激活情况报表」或「导出套餐续费情况报表」页面 +- **WHEN** 页面查询导出任务列表 +- **THEN** 系统 MUST call `GET /api/admin/export-tasks` with `scene=operations_activation` 或 `scene=operations_renewal` + +#### Scenario: Gate new scene actions + +- **WHEN** 任一新增场景页面渲染详情与下载操作 +- **THEN** 系统 MUST 使用对应场景的 `export_task::detail` 与 `export_task::download` 权限码 + +### Requirement: Export Scene Display Names + +导出任务创建弹窗与导出任务列表 SHALL 按最新业务口径展示场景名称:`commission_record` 显示为「佣金明细」(原「佣金记录」),`package_traffic_alert` 显示为「套餐真流量达量预警」(原「套餐真流量预警」);新增场景依次显示为「临期资产」「设备激活情况报表」「套餐续费情况报表」。 + +#### Scenario: Show renamed scene names + +- **WHEN** 导出弹窗以 `scene=commission_record` 打开 +- **THEN** 弹窗 MUST 显示「佣金明细」 +- **AND** 以 `scene=package_traffic_alert` 打开时 MUST 显示「套餐真流量达量预警」 + +#### Scenario: Show new scene names + +- **WHEN** 导出弹窗以 `scene=operations_activation` 打开 +- **THEN** 弹窗 MUST 显示「设备激活情况报表」 diff --git a/openspec/changes/update-operations-reports-trend-and-export-scenes/specs/operations-reports/spec.md b/openspec/changes/update-operations-reports-trend-and-export-scenes/specs/operations-reports/spec.md new file mode 100644 index 0000000..04bd139 --- /dev/null +++ b/openspec/changes/update-operations-reports-trend-and-export-scenes/specs/operations-reports/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: 运营报表趋势分析 + +系统 SHALL 在仪表台分析页(`/dashboard/analysis`)以「设备激活趋势」「套餐续费趋势」两个组件展示运营报表趋势数据。每个组件 SHALL 提供时间范围选择、统计粒度下拉(按日/按月)与刷新操作,并复用 `OperationsTrendChart` 以时间点为横轴渲染关键指标曲线。两个组件 SHALL 仅对超级管理员/平台账号可见。 + +#### Scenario: 展示激活趋势 + +- **WHEN** 用户在仪表台分析页展开「设备激活趋势」组件并选择时间范围 +- **THEN** 请求 `GET /api/admin/operations-reports/activation-trend` 并按所选 `granularity`(日/月)渲染多系列曲线 + +#### Scenario: 切换统计粒度 + +- **WHEN** 用户将统计粒度从「按日」切换为「按月」 +- **THEN** 组件以 `granularity=month` 重新请求趋势接口并刷新图表 + +## MODIFIED Requirements + +### Requirement: 运营报表页面 + +系统 SHALL 提供顶层菜单「运营报表」及两个子页「设备激活报表」「套餐续费报表」。每页 SHALL 包含时间范围与分组筛选、数据快照信息、汇总表格与导出按钮;页面 SHALL NOT 展示「汇总数据」标题,也 SHALL NOT 在页面内渲染趋势图或提供统计粒度筛选。 + +#### Scenario: 查询汇总并渲染表格 + +- **WHEN** 用户选择时间范围与分组后点击查询 +- **THEN** 请求对应汇总接口并以表格展示各分组指标与合计行 + +#### Scenario: 导出报表 + +- **WHEN** 用户点击导出并成功创建异步任务 +- **THEN** 前端轮询任务状态,任务完成后可下载文件 diff --git a/openspec/changes/update-operations-reports-trend-and-export-scenes/tasks.md b/openspec/changes/update-operations-reports-trend-and-export-scenes/tasks.md new file mode 100644 index 0000000..6a4aff1 --- /dev/null +++ b/openspec/changes/update-operations-reports-trend-and-export-scenes/tasks.md @@ -0,0 +1,24 @@ +# Implementation Tasks + +## 1. 运营报表页面调整 + +- [x] 1.1 移除 `activation/index.vue`、`renewal/index.vue` 表头「汇总数据」标题(保留数据快照与导出操作) +- [x] 1.2 移除报表页趋势图卡片、趋势相关导入/状态/请求及 `granularity` 搜索项 +- [x] 1.3 清理报表页趋势相关样式(`.report-toolbar`、`.report-title` 及 `.report-snapshot` 的 margin) + +## 2. 趋势分析迁入仪表台分析页 + +- [x] 2.1 新增 `widget/OperationsActivationTrend.vue`、`widget/OperationsRenewalTrend.vue`(时间范围 + 统计粒度下拉 + 刷新,复用 `OperationsTrendChart`) +- [x] 2.2 在 `views/dashboard/analysis/index.vue` 注册两个趋势组件,门禁使用 `isPlatformAccount` + +## 3. 导出场景补齐 + +- [x] 3.1 `ExportTaskScene` 新增 `expiring_asset` / `operations_activation` / `operations_renewal` +- [x] 3.2 `EXPORT_TASK_SCENE_CONFIG` 新增三个场景配置,并修正 `commission_record`→佣金明细、`package_traffic_alert`→套餐真流量达量预警 +- [x] 3.3 `routesAlias.ts` 与 `asyncRoutes.ts` 新增三个导出任务列表子路由 +- [x] 3.4 `zh.json` / `en.json` 新增导出场景菜单标题并同步更新命名 + +## 4. 验证 + +- [x] 4.1 `npx eslint --fix`、`npx stylelint ... --fix`、`npx vue-tsc --noEmit`、`npx vite build --mode production` +- [x] 4.2 `npx openspec validate update-operations-reports-trend-and-export-scenes --strict` diff --git a/openspec/changes/update-writeoff-application-and-superadmin-commission/design.md b/openspec/changes/update-writeoff-application-and-superadmin-commission/design.md new file mode 100644 index 0000000..83a9c0c --- /dev/null +++ b/openspec/changes/update-writeoff-application-and-superadmin-commission/design.md @@ -0,0 +1,41 @@ +## Context + +- 核销申请弹窗 `ApplicationFormDialog.vue` 同时服务于两处入口:账单列表页传 `presetBill`(含 `debtor_snapshot`),申请列表页传 `application`(`EmployeeCollectionApplication` 不含 `debtor_snapshot`)。 +- 收款方式接口 `GET /api/admin/employee-collection-payment-methods` 支持 `keyword/page/page_size/enabled`;账单接口支持 `source_type/source_no/status/customer_id/created_from/created_to` 与 `page/page_size`。 +- 「我的佣金」三页签为同一文件 `my-commission/index.vue`,接口均以 `shop_id` 作路径参数;超管账号无 `shop_id`,现有逻辑直接提示「未关联店铺」并 return。 + +## Goals / Non-Goals + +- Goals:弹窗数据可筛选、可分页、交互体验一致;代办原因语义准确;超管可查看任意店铺的佣金数据。 +- Non-Goals:不改动后端接口契约(仅使用现有查询参数);不重构账单列表页与申请列表页。 + +## Decisions + +### 抽屉替代对话框 +使用 `ElDrawer`(`direction="rtl"`,宽度适配表单内容),保留原有表单校验、`destroy-on-close` 语义与底部操作按钮;关闭时沿用既有重置逻辑。 + +### 收款方式远程搜索 +改为 `ElSelect` + `filterable` + `remote`,请求 `getPaymentMethods({ page: 1, page_size: 20, keyword, enabled: true })`;输入经防抖后请求,并以请求序号丢弃过期响应,避免竞态。编辑场景需缓存已选项,保证不在首屏结果内时仍能回显名称。 + +### 核销账单候选筛选与分页 +沿用 `EmployeeCollectionBillQueryParams`,默认 `page_size=20`;新增与账单列表页一致的筛选表单,「店铺」映射 `customer_id` 并用 `ShopService.getShops` 远程搜索。已选账单与核销金额使用独立于当前页的集合维护,翻页/筛选不清空,核销合计覆盖所有已选账单。 + +### 代办原因判定 +`isActing` 改为「当前账号 `user_type === 1` 且存在非本人负责的所选账单」。责任员工取所选账单 `debtor_snapshot.account_id`,缺失时回退 `debtor_account_id`;未选择账单或无法取得责任员工信息时,超管视为代办并要求填写。 + +### 我的佣金全局店铺选择 +页面顶部新增 `ElSelect`,仅 `userStore.info.user_type === 1` 时展示,选项经 `ShopService.getShops` 远程搜索。`currentShopId = isSuperAdmin ? selectedShopId : userStore.info?.shop_id`;切换店铺时重置分页并刷新概览与当前页签。超管未选择时展示选择提示,而非阻断。 + +## Risks / Trade-offs + +- 账单候选分页后,用户可能因筛选条件过窄而看不到目标账单;通过保留筛选条件与已选集合降低影响。 +- 代办判定依赖 `debtor_snapshot` / `debtor_account_id` 数据完整性;数据缺失时按「代办必填」处理,偏保守但符合合规要求。 + +## Migration + +- 无数据迁移。行为变化:超管本人办理核销申请不再要求填写代办原因。 + +## Open Questions + +- 账单接口是否支持按 `shop_id` 过滤;现有类型仅有 `customer_id`,若后续新增需同步 `EmployeeCollectionBillQueryParams`。 +- 超管在「提现资料」页签提交/替换资格时,是否应使用所选店铺 `shop_id`(预期为是)。 diff --git a/openspec/changes/update-writeoff-application-and-superadmin-commission/proposal.md b/openspec/changes/update-writeoff-application-and-superadmin-commission/proposal.md new file mode 100644 index 0000000..311af31 --- /dev/null +++ b/openspec/changes/update-writeoff-application-and-superadmin-commission/proposal.md @@ -0,0 +1,23 @@ +# Change: 核销申请弹窗体验优化与超管按店铺查看我的佣金 + +## Why + +创建/修改核销申请弹窗的收款方式与核销账单候选各一次性拉取 100 条且无筛选,数据量大时难以定位;弹窗为居中对话框,账单筛选与表单内容在长列表中体验不佳;代办原因仅以「是否超管」判定,超管为本人办理时仍被强制填写,与「本人办理无需代办原因」的业务语义不符。 + +同时「我的佣金」页面所有接口均以当前账号 `shop_id` 作为路径参数,超级管理员账号没有 `shop_id`,进入页面即被提示「未关联店铺」且无法查看任何数据。 + +## What Changes + +- 核销申请弹窗由居中 `ElDialog` 调整为右侧滑出抽屉。 +- 收款方式改为远程搜索下拉:按 `keyword` 搜索,默认拉取 20 条(不再固定 100 条),仅展示启用项。 +- 核销账单候选列表:默认按 20 条分页加载(不再固定 100 条),新增按来源、来源单号、核销状态、店铺、起止时间的筛选表单,筛选变化后回到第 1 页,翻页保持已选账单。 +- 代办原因(`acting_reason`)判定改为「超级管理员且所选账单责任员工非本人」:当所选账单的 `debtor_snapshot.account_id` 不等于当前登录账号 `id`(或未选择账单 / 无责任员工信息)时展示且必填;所选账单全部归属于当前登录账号时不展示、不提交该字段。**BREAKING**:超管本人办理不再强制填写代办原因。 +- 「我的佣金」页面顶部为超级管理员新增全局店铺下拉(远程搜索店铺),选中店铺后三个页签(佣金明细 / 提现记录 / 提现资料)均以所选 `shop_id` 调用接口;非超级管理员不展示该下拉,继续使用自身 `shop_id`。 + +## Impact + +- Affected specs: `employee-collection`、`commission-management` +- Affected code: + - `src/views/finance/employee-collection/applications/components/ApplicationFormDialog.vue` + - `src/views/commission-management/my-commission/index.vue` + - `src/types/api/employeeCollection.ts`(如需补充账单候选查询字段类型) diff --git a/openspec/changes/update-writeoff-application-and-superadmin-commission/specs/commission-management/spec.md b/openspec/changes/update-writeoff-application-and-superadmin-commission/specs/commission-management/spec.md new file mode 100644 index 0000000..f75253f --- /dev/null +++ b/openspec/changes/update-writeoff-application-and-superadmin-commission/specs/commission-management/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: 超级管理员按店铺查看我的佣金 +「我的佣金」页面 MUST 为超级管理员(`user_type` 为 `1`,其账号无 `shop_id`)在页面顶部提供全局店铺选择下拉,选项 MUST 通过店铺列表接口远程搜索获取。选中店铺后,佣金概览与三个页签(佣金明细、提现记录、提现资料)的接口 MUST 使用所选 `shop_id`;切换店铺 MUST 刷新概览与当前页签数据。非超级管理员 MUST NOT 展示该下拉,且 MUST 继续使用当前账号自身的 `shop_id`。超级管理员未选择店铺时,页面 MUST 展示选择店铺的提示,MUST NOT 以「未关联店铺」为由阻止其使用。 + +#### Scenario: 超管选择店铺后查看数据 +- **GIVEN** 超级管理员进入「我的佣金」页面 +- **WHEN** 其通过顶部下拉远程搜索并选择一个店铺 +- **THEN** 佣金概览与当前页签 MUST 以所选 `shop_id` 调用接口 +- **AND** 切换到其他页签 MUST 继续使用同一 `shop_id` + +#### Scenario: 超管未选择店铺 +- **GIVEN** 超级管理员进入「我的佣金」页面且尚未选择店铺 +- **WHEN** 页面初始化 +- **THEN** 页面 MUST 提示先选择店铺 +- **AND** MUST NOT 展示「未关联店铺」的阻断提示 + +#### Scenario: 非超管不展示店铺下拉 +- **GIVEN** 当前登录账号不是超级管理员 +- **WHEN** 其进入「我的佣金」页面 +- **THEN** 页面 MUST NOT 展示店铺下拉 +- **AND** MUST 使用自身 `shop_id` 加载数据 + +#### Scenario: 切换店铺刷新数据 +- **GIVEN** 超级管理员已选择店铺并加载数据 +- **WHEN** 其切换为另一个店铺 +- **THEN** 前端 MUST 重新加载佣金概览与当前页签列表 +- **AND** 分页 MUST 重置到第 1 页 diff --git a/openspec/changes/update-writeoff-application-and-superadmin-commission/specs/employee-collection/spec.md b/openspec/changes/update-writeoff-application-and-superadmin-commission/specs/employee-collection/spec.md new file mode 100644 index 0000000..7ff136d --- /dev/null +++ b/openspec/changes/update-writeoff-application-and-superadmin-commission/specs/employee-collection/spec.md @@ -0,0 +1,64 @@ +## ADDED Requirements + +### Requirement: 核销申请弹窗抽屉与收款方式远程搜索 +创建/修改核销申请弹窗 MUST 以从右侧滑出的抽屉形式呈现,并 MUST 保留表单校验、关闭时重置与底部提交/取消操作。收款方式 MUST 改为远程搜索下拉:请求 MUST 携带 `keyword`、`page_size` 为 `20`、`enabled` 为 `true`,仅展示启用项;输入关键字 MUST 经防抖后发起请求,并发时 MUST 丢弃过期响应。编辑已提交申请时,MUST 能回显并保留当前已选收款方式,即使其不在当前搜索结果内。 + +#### Scenario: 打开弹窗 +- **GIVEN** 用户点击创建或修改核销申请 +- **WHEN** 弹窗打开 +- **THEN** 弹窗 MUST 从右侧滑出 +- **AND** MUST 保留表单字段、校验与底部操作 + +#### Scenario: 收款方式远程搜索 +- **GIVEN** 用户打开收款方式下拉 +- **WHEN** 输入关键字 +- **THEN** 前端 MUST 以防抖方式调用收款方式接口,携带 `keyword`、`page_size=20`、`enabled=true` +- **AND** 仅展示接口返回的启用项 +- **AND** 过期请求的响应 MUST NOT 覆盖最新结果 + +#### Scenario: 编辑场景回显已选收款方式 +- **GIVEN** 正在修改的申请已选择某收款方式且其不在首屏搜索结果中 +- **WHEN** 弹窗初始化 +- **THEN** 下拉 MUST 回显该收款方式名称 +- **AND** MUST NOT 因搜索而清空已选值 + +### Requirement: 核销账单候选筛选与分页 +核销申请弹窗的核销账单候选列表 MUST 默认以 `page_size` 为 `20` 加载,MUST NOT 固定请求 100 条。候选列表 MUST 支持按来源(`source_type`)、来源单号(`source_no`)、核销状态(`status`)、店铺(`customer_id`)与创建时间(`created_from` / `created_to`)筛选,店铺筛选 MUST 使用远程搜索。筛选条件变化 MUST 回到第 1 页重新加载。翻页 MUST NOT 清空已选账单与已填核销金额;当前页之外已选账单 MUST 仍计入核销合计并随申请提交。 + +#### Scenario: 默认分页加载 +- **GIVEN** 用户打开核销申请弹窗 +- **WHEN** 账单候选列表加载 +- **THEN** 请求 MUST 使用 `page_size=20` +- **AND** MUST 提供分页或加载更多入口以访问后续数据 + +#### Scenario: 按条件筛选账单 +- **GIVEN** 用户已打开核销申请弹窗 +- **WHEN** 其填写来源、来源单号、核销状态、店铺或起止时间中的任意条件 +- **THEN** 前端 MUST 以对应查询参数从第 1 页重新加载候选账单 +- **AND** 店铺 MUST 通过远程搜索选择 + +#### Scenario: 翻页保持已选 +- **GIVEN** 用户已在当前页勾选账单并填写核销金额 +- **WHEN** 其翻页后返回或直接提交 +- **THEN** 已勾选账单与核销金额 MUST 保持不变 +- **AND** 核销合计 MUST 包含所有页已选账单 + +### Requirement: 代办原因按所选账单责任员工判定 +核销申请的 `acting_reason` MUST 仅在「当前登录账号为超级管理员(`user_type` 为 `1`)且存在非本人负责的所选账单」时展示并必填。责任员工 MUST 取自账单 `debtor_snapshot.account_id`,缺失时回退 `debtor_account_id`。当所选账单全部归属于当前登录账号时,前端 MUST NOT 展示且 MUST NOT 提交 `acting_reason`;当尚未选择任何账单或无法取得责任员工信息时,超级管理员 MUST 视为代办并要求填写。 + +#### Scenario: 超管为他人代办需填原因 +- **GIVEN** 当前登录账号为超级管理员 +- **WHEN** 其选择责任员工 `account_id` 不等于当前账号 `id` 的账单 +- **THEN** 代办原因字段 MUST 展示且必填 +- **AND** 未填写即提交 MUST 被阻止 + +#### Scenario: 超管本人办理无需原因 +- **GIVEN** 当前登录账号为超级管理员且所选账单全部由其本人负责 +- **WHEN** 其提交核销申请 +- **THEN** 代办原因字段 MUST NOT 展示 +- **AND** 提交载荷 MUST NOT 包含 `acting_reason` + +#### Scenario: 非超管不判定代办 +- **GIVEN** 当前登录账号不是超级管理员 +- **WHEN** 其创建或重新提交核销申请 +- **THEN** 代办原因字段 MUST NOT 展示 diff --git a/openspec/changes/update-writeoff-application-and-superadmin-commission/tasks.md b/openspec/changes/update-writeoff-application-and-superadmin-commission/tasks.md new file mode 100644 index 0000000..96fe7da --- /dev/null +++ b/openspec/changes/update-writeoff-application-and-superadmin-commission/tasks.md @@ -0,0 +1,23 @@ +## 1. 核销申请弹窗 + +- [x] 1.1 弹窗由 `ElDialog` 改为右侧 `ElDrawer`,保留表单校验、关闭重置与底部提交/取消操作 +- [x] 1.2 收款方式改为远程搜索下拉:携带 `keyword`、`page_size=20`、`enabled=true`,防抖并发且丢弃过期响应 +- [x] 1.3 核销账单候选默认按 20 条分页加载,新增来源 / 来源单号 / 核销状态 / 店铺 / 起止时间筛选表单 +- [x] 1.4 店铺筛选使用远程搜索(复用账单列表页 `customer_id` + `ShopService.getShops` 范式) +- [x] 1.5 筛选条件变化回到第 1 页;翻页保持已选账单与已填核销金额 +- [x] 1.6 代办原因按所选账单责任员工判定(超管且非本人时展示并必填,本人办理不展示不提交) + +## 2. 我的佣金 + +- [x] 2.1 页面顶部新增超管可见的全局店铺下拉(远程搜索,复用 `ShopService.getShops`) +- [x] 2.2 `currentShopId` 改为超管取所选店铺、非超管取自身 `shop_id` +- [x] 2.3 超管未选择店铺时展示选择提示,不再以「未关联店铺」阻断 +- [x] 2.4 三个页签(佣金明细 / 提现记录 / 提现资料)与概览均使用所选 `shop_id` +- [x] 2.5 切换店铺时重置分页并刷新概览与当前页签数据 + +## 3. 验证 + +- [x] 3.1 改动文件 `npx eslint` 通过(必要时 `--fix`) +- [x] 3.2 `npx vue-tsc --noEmit` 通过 +- [x] 3.3 `npx vite build --mode production` 通过 +- [x] 3.4 `npx openspec validate update-writeoff-application-and-superadmin-commission --strict` 通过 diff --git a/src/api/modules/asset.ts b/src/api/modules/asset.ts index d68f867..007c5c0 100644 --- a/src/api/modules/asset.ts +++ b/src/api/modules/asset.ts @@ -31,7 +31,8 @@ import type { UpdateAssetPackageUsedDataRequest, UpdateAssetPackageExpiresAtRequest, ExpiringAssetListResponse, - ExpiringAssetQueryParams + ExpiringAssetQueryParams, + CreateExportTaskResponse } from '@/types/api' const runRateLimitedAssetAction = async ( @@ -54,6 +55,19 @@ export class AssetService extends BaseService { ): Promise> { return this.get>('/api/admin/expiring-assets', params) } + + /** + * 导出临期资产(异步导出任务) + * POST /api/admin/expiring-assets/export + */ + static exportExpiringAssets( + params?: ExpiringAssetQueryParams & { format?: 'xlsx' | 'csv' } + ): Promise> { + return this.post>( + '/api/admin/expiring-assets/export', + params + ) + } /** * 通过任意标识符查询设备或卡的完整详情 * 支持虚拟号、ICCID、IMEI、SN、MSISDN diff --git a/src/api/modules/exchange.ts b/src/api/modules/exchange.ts index 61d78d3..bbf370c 100644 --- a/src/api/modules/exchange.ts +++ b/src/api/modules/exchange.ts @@ -18,8 +18,8 @@ export interface ExchangeQueryParams { flow_type?: ExchangeFlowType // 流程类型(shipping/direct) old_asset_keyword?: string // 旧资产关键词(ICCID、接入号、虚拟号、IMEI、SN) new_asset_keyword?: string // 新资产关键词(ICCID、接入号、虚拟号、IMEI、SN) - created_at_start?: string // 创建时间起始 - created_at_end?: string // 创建时间结束 + start_time?: string // 创建时间起始 + end_time?: string // 创建时间结束 } // 创建换货单请求 diff --git a/src/api/modules/index.ts b/src/api/modules/index.ts index 442386c..27a1afa 100644 --- a/src/api/modules/index.ts +++ b/src/api/modules/index.ts @@ -57,3 +57,5 @@ export { PackageTrafficAlertService } from './packageTrafficAlert' export { PollingPriorityQueueService } from './pollingPriorityQueue' export { AssetWalletService } from './assetWallet' + +export { OperationsReportsService } from './operationsReports' diff --git a/src/api/modules/operationsReports.ts b/src/api/modules/operationsReports.ts new file mode 100644 index 0000000..6e2ab6b --- /dev/null +++ b/src/api/modules/operationsReports.ts @@ -0,0 +1,65 @@ +import { BaseService } from '../BaseService' +import type { + ActivationSummaryApiResponse, + ActivationTrendApiResponse, + OperationsExportParams, + OperationsExportTaskApiResponse, + OperationsReportQueryParams, + OperationsTrendQueryParams, + RenewalSummaryApiResponse, + RenewalTrendApiResponse +} from '@/types/api' + +export class OperationsReportsService extends BaseService { + static getActivationSummary( + params?: OperationsReportQueryParams + ): Promise { + return this.get( + '/api/admin/operations-reports/activation-summary', + params + ) + } + + static getActivationTrend( + params?: OperationsTrendQueryParams + ): Promise { + return this.get( + '/api/admin/operations-reports/activation-trend', + params + ) + } + + static exportActivationSummary( + data: OperationsExportParams + ): Promise { + return this.post( + '/api/admin/operations-reports/activation-summary/export', + data + ) + } + + static getRenewalSummary( + params?: OperationsReportQueryParams + ): Promise { + return this.get( + '/api/admin/operations-reports/package-renewal-summary', + params + ) + } + + static getRenewalTrend(params?: OperationsTrendQueryParams): Promise { + return this.get( + '/api/admin/operations-reports/package-renewal-trend', + params + ) + } + + static exportRenewalSummary( + data: OperationsExportParams + ): Promise { + return this.post( + '/api/admin/operations-reports/package-renewal-summary/export', + data + ) + } +} diff --git a/src/components/business/VoucherUpload.vue b/src/components/business/VoucherUpload.vue index a629055..4b92689 100644 --- a/src/components/business/VoucherUpload.vue +++ b/src/components/business/VoucherUpload.vue @@ -28,9 +28,10 @@
@@ -47,6 +48,13 @@
+
@@ -94,6 +102,7 @@ const rootRef = ref() const uploadRef = ref() + const currentUploadFiles = ref([]) const uploadingCount = ref(0) const voucherFileKeyMap = new Map() const voucherFileMetadataMap = new Map() @@ -131,6 +140,7 @@ removedUploadUids.clear() selectedUploadUids.clear() revokeAllFileObjectUrls() + currentUploadFiles.value = [] uploadRef.value?.clearFiles() if (emitValue) { emitVoucherKeys() @@ -192,6 +202,22 @@ uploadRef.value?.handleRemove(uploadFile) } + const previewVisible = ref(false) + const previewUrls = ref([]) + const previewIndex = ref(0) + + const openImagePreview = (uploadFile: UploadFile) => { + const urls = currentUploadFiles.value + .filter((file) => isUploadImage(file)) + .map((file) => getUploadFileUrl(file)) + .filter((url) => !!url) + if (!urls.length) return + + previewUrls.value = urls + previewIndex.value = Math.max(0, urls.indexOf(getUploadFileUrl(uploadFile))) + previewVisible.value = true + } + // accept 支持扩展名(.csv)、精确 MIME(image/jpeg)与通配 MIME(image/*) const isAcceptMatched = (file: File) => { if (!props.accept) return true @@ -221,7 +247,9 @@ return `只能上传 ${props.accept} 格式的文件` } - const handleFileChange = async (uploadFile: UploadFile) => { + const handleFileChange = async (uploadFile: UploadFile, uploadFiles: UploadFile[]) => { + currentUploadFiles.value = [...uploadFiles] + const file = uploadFile.raw if (!file) return @@ -320,7 +348,8 @@ } } - const handleRemoveFile = (uploadFile: UploadFile) => { + const handleRemoveFile = (uploadFile: UploadFile, uploadFiles: UploadFile[]) => { + currentUploadFiles.value = [...uploadFiles] removedUploadUids.add(uploadFile.uid) selectedUploadUids.delete(uploadFile.uid) revokeFileObjectUrl(uploadFile.uid) @@ -478,6 +507,10 @@ border-radius: 6px; } + &__file-cover--image { + cursor: zoom-in; + } + &__file-cover--default { display: flex; align-items: center; diff --git a/src/config/constants/exportTask.ts b/src/config/constants/exportTask.ts index ec4b2d5..4e8048a 100644 --- a/src/config/constants/exportTask.ts +++ b/src/config/constants/exportTask.ts @@ -87,8 +87,8 @@ export const EXPORT_TASK_SCENE_CONFIG: Record {} + +export interface ActivationSummaryData { + group_by: string | null + group_name: string + has_snapshot: boolean + snapshot_dates: string[] + items: ActivationSummaryItem[] + totals: ActivationSummaryTotals | null +} + +export interface ActivationTrendPoint { + period: string + purchase_count?: number + activated_count?: number + activation_rate?: number | null + new_activated_count?: number + online_count?: number + active_user_count?: number + cumulative_usage_mb?: number + [key: string]: unknown +} + +export interface ActivationTrendData { + granularity: string + group_by: string | null + group_name: string + points: ActivationTrendPoint[] +} + +export interface RenewalSummaryItem { + group_key?: string + group_name?: string + expiring_asset_count?: number + renewed_asset_count?: number + renewal_rate?: number | null + new_unrenewed_count?: number + [key: string]: unknown +} + +export interface RenewalSummaryTotals extends Partial {} + +export interface RenewalSummaryData { + group_by: string | null + group_name: string + has_snapshot: boolean + snapshot_dates: string[] + items: RenewalSummaryItem[] + totals: RenewalSummaryTotals | null +} + +export interface RenewalTrendPoint { + period: string + expiring_asset_count?: number + renewed_asset_count?: number + renewal_rate?: number | null + new_unrenewed_count?: number + [key: string]: unknown +} + +export interface RenewalTrendData { + granularity: string + group_by: string | null + group_name: string + points: RenewalTrendPoint[] +} + +export type ActivationSummaryApiResponse = BaseResponse +export type ActivationTrendApiResponse = BaseResponse +export type RenewalSummaryApiResponse = BaseResponse +export type RenewalTrendApiResponse = BaseResponse +export type OperationsExportTaskApiResponse = BaseResponse diff --git a/src/types/components.d.ts b/src/types/components.d.ts index e1bf522..c5fd52c 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -116,6 +116,7 @@ declare module 'vue' { ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElIcon: typeof import('element-plus/es')['ElIcon'] + ElImageViewer: typeof import('element-plus/es')['ElImageViewer'] ElInput: typeof import('element-plus/es')['ElInput'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElMenu: typeof import('element-plus/es')['ElMenu'] diff --git a/src/views/asset-management/exchange-management/index.vue b/src/views/asset-management/exchange-management/index.vue index 7ff58d3..9e8e83d 100644 --- a/src/views/asset-management/exchange-management/index.vue +++ b/src/views/asset-management/exchange-management/index.vue @@ -478,8 +478,8 @@ old_asset_keyword: '', new_asset_keyword: '', created_at_range: [], - created_at_start: '', - created_at_end: '' + start_time: '', + end_time: '' }) const exportQuery = computed(() => { @@ -489,8 +489,8 @@ flow_type: searchForm.flow_type, old_asset_keyword: searchForm.old_asset_keyword || undefined, new_asset_keyword: searchForm.new_asset_keyword || undefined, - created_at_start: startDate || searchForm.created_at_start || undefined, - created_at_end: endDate || searchForm.created_at_end || undefined + start_time: startDate || searchForm.start_time || undefined, + end_time: endDate || searchForm.end_time || undefined } }) @@ -1059,11 +1059,11 @@ if (searchForm.new_asset_keyword) { params.new_asset_keyword = searchForm.new_asset_keyword } - if (searchForm.created_at_start) { - params.created_at_start = searchForm.created_at_start + if (searchForm.start_time) { + params.start_time = searchForm.start_time } - if (searchForm.created_at_end) { - params.created_at_end = searchForm.created_at_end + if (searchForm.end_time) { + params.end_time = searchForm.end_time } const res = await ExchangeService.getExchanges(params) @@ -1082,11 +1082,11 @@ const handleSearch = () => { // 处理日期范围 if (searchForm.created_at_range && Array.isArray(searchForm.created_at_range)) { - searchForm.created_at_start = searchForm.created_at_range[0] || '' - searchForm.created_at_end = searchForm.created_at_range[1] || '' + searchForm.start_time = searchForm.created_at_range[0] || '' + searchForm.end_time = searchForm.created_at_range[1] || '' } else { - searchForm.created_at_start = '' - searchForm.created_at_end = '' + searchForm.start_time = '' + searchForm.end_time = '' } pagination.page = 1 loadExchangeList() @@ -1097,8 +1097,8 @@ searchForm.old_asset_keyword = '' searchForm.new_asset_keyword = '' searchForm.created_at_range = [] - searchForm.created_at_start = '' - searchForm.created_at_end = '' + searchForm.start_time = '' + searchForm.end_time = '' pagination.page = 1 loadExchangeList() } diff --git a/src/views/asset-management/expiring-assets/index.vue b/src/views/asset-management/expiring-assets/index.vue index 84ec8f9..38caba2 100644 --- a/src/views/asset-management/expiring-assets/index.vue +++ b/src/views/asset-management/expiring-assets/index.vue @@ -21,6 +21,13 @@ 临期资产 {{ summary.total_count }} 条(卡 {{ summary.card_count }},设备 {{ summary.device_count }}),0-3 天资产优先显示。 + + {{ exportTask.status_name || '处理中' }} + + + 下载 + + 导出 @@ -49,17 +56,20 @@ import { h, onMounted, reactive, ref, computed } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElButton, ElMessage, ElTag } from 'element-plus' - import { AssetService, PackageManageService, ShopService } from '@/api/modules' - import type { - ExpiringAssetItem, - ExpiringAssetSummary, - ExpiringAssetType, - ExpiringAssetQueryParams, - PackageResponse, - ShopResponse + import { AssetService, ExportTaskService, PackageManageService, ShopService } from '@/api/modules' + import { + ExportTaskStatus, + type ExpiringAssetItem, + type ExpiringAssetSummary, + type ExpiringAssetType, + type ExpiringAssetQueryParams, + type ExportTaskDetail, + type PackageResponse, + type ShopResponse } from '@/types/api' import type { SearchFormItem } from '@/types' import { RoutesAlias } from '@/router/routesAlias' + import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling' import { useCheckedColumns } from '@/composables/useCheckedColumns' import { formatDateTime } from '@/utils/business/format' @@ -69,6 +79,7 @@ const route = useRoute() const tableRef = ref() const loading = ref(false) + const exporting = ref(false) const shopLoading = ref(false) const packageLoading = ref(false) const shopOptions = ref([]) @@ -87,8 +98,8 @@ package_id: undefined, days_min: undefined, days_max: undefined, - expires_from: undefined, - expires_to: undefined, + start_time: undefined, + end_time: undefined, expires_range: [] }) const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 }) @@ -290,37 +301,41 @@ } ]) + const toInteger = (value: unknown) => { + if (value === undefined || value === null || value === '') return undefined + const numberValue = Number(value) + return Number.isInteger(numberValue) ? numberValue : undefined + } + + const buildFilterParams = (includePagination = true): ExpiringAssetQueryParams => { + const params: ExpiringAssetQueryParams = includePagination + ? { page: pagination.currentPage, page_size: pagination.pageSize } + : {} + + if (searchForm.asset_type) params.asset_type = searchForm.asset_type + const keyword = searchForm.keyword?.trim() + if (keyword) params.keyword = keyword + if (searchForm.shop_id !== undefined && searchForm.shop_id !== null) { + params.shop_id = searchForm.shop_id + } + if (searchForm.package_id !== undefined && searchForm.package_id !== null) { + params.package_id = searchForm.package_id + } + const daysMin = toInteger(searchForm.days_min) + const daysMax = toInteger(searchForm.days_max) + if (daysMin !== undefined) params.days_min = daysMin + if (daysMax !== undefined) params.days_max = daysMax + const [expiresFrom, expiresTo] = searchForm.expires_range || [] + if (expiresFrom) params.start_time = expiresFrom + if (expiresTo) params.end_time = expiresTo + + return params + } + const loadAssets = async () => { loading.value = true try { - const toInteger = (value: unknown) => { - if (value === undefined || value === null || value === '') return undefined - const numberValue = Number(value) - return Number.isInteger(numberValue) ? numberValue : undefined - } - const params: ExpiringAssetQueryParams = { - page: pagination.currentPage, - page_size: pagination.pageSize - } - - if (searchForm.asset_type) params.asset_type = searchForm.asset_type - const keyword = searchForm.keyword?.trim() - if (keyword) params.keyword = keyword - if (searchForm.shop_id !== undefined && searchForm.shop_id !== null) { - params.shop_id = searchForm.shop_id - } - if (searchForm.package_id !== undefined && searchForm.package_id !== null) { - params.package_id = searchForm.package_id - } - const daysMin = toInteger(searchForm.days_min) - const daysMax = toInteger(searchForm.days_max) - if (daysMin !== undefined) params.days_min = daysMin - if (daysMax !== undefined) params.days_max = daysMax - const [expiresFrom, expiresTo] = searchForm.expires_range || [] - if (expiresFrom) params.expires_from = expiresFrom - if (expiresTo) params.expires_to = expiresTo - - const response = await AssetService.getExpiringAssets(params) + const response = await AssetService.getExpiringAssets(buildFilterParams()) if (response.code === 0 && response.data) { items.value = response.data.items || [] pagination.total = response.data.total || 0 @@ -349,8 +364,8 @@ package_id: undefined, days_min: undefined, days_max: undefined, - expires_from: undefined, - expires_to: undefined, + start_time: undefined, + end_time: undefined, expires_range: [] }) pagination.currentPage = 1 @@ -367,6 +382,64 @@ void loadAssets() } + const exportTask = computed(() => exportPolling.task.value) + const canDownload = computed( + () => + exportTask.value?.status === ExportTaskStatus.COMPLETED && !!exportTask.value?.download_url + ) + const exportStatusType = computed(() => { + const status = exportTask.value?.status + if (status === ExportTaskStatus.COMPLETED) return 'success' + if (status === ExportTaskStatus.FAILED) return 'danger' + if (status === ExportTaskStatus.CANCELED) return 'info' + return 'warning' + }) + + const exportPolling = useAsyncTaskPolling({ + storageKey: 'expiring-assets:export', + fetchTask: async (taskId) => { + const res = await ExportTaskService.getExportTaskDetail(taskId) + if (res.code === 403) { + const error = new Error('暂无权限查看导出任务') as Error & { status?: number } + error.status = 403 + throw error + } + if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取导出任务详情失败') + return res.data + }, + isForbidden: (error: any) => error?.status === 403 || error?.response?.status === 403 + }) + + const handleExport = async () => { + exporting.value = true + try { + const res = await AssetService.exportExpiringAssets({ + ...buildFilterParams(false), + format: 'xlsx' + }) + if (res.code !== 0 || !res.data) { + ElMessage.error(res.msg || '导出失败') + return + } + ElMessage.success(res.data.message || '导出任务已创建,请稍候') + await exportPolling.start(res.data.task_id) + } catch (error) { + console.error('导出临期资产失败:', error) + ElMessage.error('导出临期资产失败') + } finally { + exporting.value = false + } + } + + const downloadExport = () => { + const url = exportTask.value?.download_url + if (!url) { + ElMessage.warning('当前任务暂无可用下载地址') + return + } + window.open(url, '_blank') + } + const goToAsset = (row: ExpiringAssetItem) => { router.push({ path: RoutesAlias.AssetInformation, @@ -387,9 +460,15 @@ diff --git a/src/views/dashboard/analysis/widget/OperationsRenewalTrend.vue b/src/views/dashboard/analysis/widget/OperationsRenewalTrend.vue new file mode 100644 index 0000000..1967fc7 --- /dev/null +++ b/src/views/dashboard/analysis/widget/OperationsRenewalTrend.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/src/views/finance/agent-recharge/index.vue b/src/views/finance/agent-recharge/index.vue index b3cda8c..dfe4354 100644 --- a/src/views/finance/agent-recharge/index.vue +++ b/src/views/finance/agent-recharge/index.vue @@ -481,8 +481,8 @@ status: undefined, recharge_source: undefined, dateRange: [], - start_date: '', - end_date: '' + start_time: '', + end_time: '' } // 搜索表单 @@ -1032,8 +1032,8 @@ shop_id: isPlatformAccount.value ? searchForm.shop_id : undefined, status: searchForm.status, recharge_source: searchForm.recharge_source, - start_date: searchForm.start_date || undefined, - end_date: searchForm.end_date || undefined + start_time: searchForm.start_time || undefined, + end_time: searchForm.end_time || undefined } const res = await AgentRechargeService.getAgentRecharges(params) if (res.code === 0) { @@ -1058,11 +1058,11 @@ const handleSearch = () => { // 处理日期范围 if (searchForm.dateRange && Array.isArray(searchForm.dateRange)) { - searchForm.start_date = searchForm.dateRange[0] - searchForm.end_date = searchForm.dateRange[1] + searchForm.start_time = searchForm.dateRange[0] + searchForm.end_time = searchForm.dateRange[1] } else { - searchForm.start_date = '' - searchForm.end_date = '' + searchForm.start_time = '' + searchForm.end_time = '' } pagination.page = 1 getTableData() @@ -1072,8 +1072,8 @@ shop_id: isPlatformAccount.value ? searchForm.shop_id : undefined, status: searchForm.status, recharge_source: searchForm.recharge_source, - start_date: searchForm.start_date || searchForm.dateRange?.[0], - end_date: searchForm.end_date || searchForm.dateRange?.[1] + start_time: searchForm.start_time || searchForm.dateRange?.[0], + end_time: searchForm.end_time || searchForm.dateRange?.[1] })) // 刷新表格 diff --git a/src/views/finance/employee-collection/applications/components/ApplicationFormDialog.vue b/src/views/finance/employee-collection/applications/components/ApplicationFormDialog.vue index f74fece..da4e1f8 100644 --- a/src/views/finance/employee-collection/applications/components/ApplicationFormDialog.vue +++ b/src/views/finance/employee-collection/applications/components/ApplicationFormDialog.vue @@ -1,51 +1,164 @@ - + + + diff --git a/src/views/operations-reports/components/OperationsTrendChart.vue b/src/views/operations-reports/components/OperationsTrendChart.vue new file mode 100644 index 0000000..f913f1f --- /dev/null +++ b/src/views/operations-reports/components/OperationsTrendChart.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/src/views/operations-reports/constants.ts b/src/views/operations-reports/constants.ts new file mode 100644 index 0000000..5c35d0e --- /dev/null +++ b/src/views/operations-reports/constants.ts @@ -0,0 +1,96 @@ +import type { Option } from '@/types' + +export interface OperationsMetricColumn { + key: string + label: string + kind: 'number' | 'ratio' + digits?: number + minWidth?: number +} + +export interface OperationsTrendSeriesConfig { + key: string + name: string + type: 'line' | 'bar' + yAxisIndex?: 0 | 1 + areaStyle?: boolean +} + +export type OperationsTrendSeries = OperationsTrendSeriesConfig & { data: (number | null)[] } + +export const ACTIVATION_GROUP_OPTIONS: Option[] = [ + { label: '全部', value: '' }, + { label: '设备名称', value: 'device_name' }, + { label: '设备型号', value: 'device_model' }, + { label: '制造商', value: 'manufacturer' }, + { label: '业务用户组', value: 'business_user_group' }, + { label: '代理商', value: 'agent' }, + { label: '店铺', value: 'shop' }, + { label: '业务负责人', value: 'business_owner' } +] + +export const RENEWAL_GROUP_OPTIONS: Option[] = [ + { label: '全部', value: '' }, + { label: '套餐系列', value: 'package_series' }, + { label: '套餐名称', value: 'package_name' }, + { label: '业务用户组', value: 'business_user_group' }, + { label: '代理商', value: 'agent' }, + { label: '店铺', value: 'shop' }, + { label: '业务负责人', value: 'business_owner' } +] + +export const GRANULARITY_OPTIONS: Option[] = [ + { label: '按日', value: 'day' }, + { label: '按月', value: 'month' } +] + +export const ACTIVATION_METRIC_COLUMNS: OperationsMetricColumn[] = [ + { key: 'purchase_count', label: '采购数量', kind: 'number', minWidth: 100 }, + { key: 'activated_count', label: '累计激活数', kind: 'number', minWidth: 110 }, + { key: 'activation_rate', label: '激活率', kind: 'ratio', minWidth: 90 }, + { key: 'new_activated_count', label: '新增激活数', kind: 'number', minWidth: 110 }, + { key: 'online_count', label: '累计在网数', kind: 'number', minWidth: 110 }, + { key: 'active_user_count', label: '活跃用户数', kind: 'number', minWidth: 110 }, + { key: 'cumulative_usage_mb', label: '累计用量(MB)', kind: 'number', minWidth: 130 }, + { key: 'avg_usage_per_card_mb', label: '卡均用量(MB)', kind: 'number', minWidth: 130 } +] + +export const RENEWAL_METRIC_COLUMNS: OperationsMetricColumn[] = [ + { key: 'expiring_asset_count', label: '到期资产数', kind: 'number', minWidth: 110 }, + { key: 'renewed_asset_count', label: '续费资产数', kind: 'number', minWidth: 110 }, + { key: 'renewal_rate', label: '续费率', kind: 'ratio', minWidth: 90 }, + { key: 'new_unrenewed_count', label: '新增未续费数', kind: 'number', minWidth: 120 } +] + +export const ACTIVATION_TREND_SERIES: OperationsTrendSeriesConfig[] = [ + { key: 'new_activated_count', name: '新增激活数', type: 'bar' }, + { key: 'activated_count', name: '累计激活数', type: 'line', areaStyle: true }, + { key: 'activation_rate', name: '激活率', type: 'line', yAxisIndex: 1 } +] + +export const RENEWAL_TREND_SERIES: OperationsTrendSeriesConfig[] = [ + { key: 'renewed_asset_count', name: '续费资产数', type: 'bar' }, + { key: 'expiring_asset_count', name: '到期资产数', type: 'line', areaStyle: true }, + { key: 'renewal_rate', name: '续费率', type: 'line', yAxisIndex: 1 } +] + +export const formatMetric = (value: unknown, column: OperationsMetricColumn): string => { + if (value === null || value === undefined || value === '') return '-' + const num = Number(value) + if (Number.isNaN(num)) return String(value) + + const digits = column.digits ?? 2 + if (column.kind === 'ratio') { + const percent = Math.abs(num) <= 1 ? num * 100 : num + return `${percent.toFixed(digits)}%` + } + + return num.toLocaleString('en-US', { maximumFractionDigits: digits }) +} + +export const normalizeRatio = (value: unknown): number | null => { + if (value === null || value === undefined || value === '') return null + const num = Number(value) + if (Number.isNaN(num)) return null + return Math.abs(num) <= 1 ? num * 100 : num +} diff --git a/src/views/operations-reports/renewal/index.vue b/src/views/operations-reports/renewal/index.vue new file mode 100644 index 0000000..16247f6 --- /dev/null +++ b/src/views/operations-reports/renewal/index.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/src/views/package-management/traffic-alert-rules/index.vue b/src/views/package-management/traffic-alert-rules/index.vue index a97f8dc..57ea4b9 100644 --- a/src/views/package-management/traffic-alert-rules/index.vue +++ b/src/views/package-management/traffic-alert-rules/index.vue @@ -16,7 +16,7 @@ v-model:columns="columnChecks" @refresh="handleRefresh" > -