This commit is contained in:
@@ -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
|
||||
|
||||
- 指标字段名未经后端确认 → 通过集中常量降低风险,联调时单点修正。
|
||||
- 报表页可能返回大量行 → 首版不做虚拟滚动,依赖后端分页/汇总;后续按需优化。
|
||||
@@ -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`
|
||||
@@ -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** 表格对应单元格显示 `-`
|
||||
@@ -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 文件
|
||||
@@ -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` 通过
|
||||
@@ -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`
|
||||
@@ -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=<scene>`),并提供 `export_task:<scene>:detail` / `export_task:<scene>: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:<scene>:detail` 与 `export_task:<scene>: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 显示「设备激活情况报表」
|
||||
@@ -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** 前端轮询任务状态,任务完成后可下载文件
|
||||
@@ -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`
|
||||
@@ -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`(预期为是)。
|
||||
@@ -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`(如需补充账单候选查询字段类型)
|
||||
@@ -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 页
|
||||
@@ -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 展示
|
||||
@@ -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` 通过
|
||||
@@ -31,7 +31,8 @@ import type {
|
||||
UpdateAssetPackageUsedDataRequest,
|
||||
UpdateAssetPackageExpiresAtRequest,
|
||||
ExpiringAssetListResponse,
|
||||
ExpiringAssetQueryParams
|
||||
ExpiringAssetQueryParams,
|
||||
CreateExportTaskResponse
|
||||
} from '@/types/api'
|
||||
|
||||
const runRateLimitedAssetAction = async <T>(
|
||||
@@ -54,6 +55,19 @@ export class AssetService extends BaseService {
|
||||
): Promise<BaseResponse<ExpiringAssetListResponse>> {
|
||||
return this.get<BaseResponse<ExpiringAssetListResponse>>('/api/admin/expiring-assets', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出临期资产(异步导出任务)
|
||||
* POST /api/admin/expiring-assets/export
|
||||
*/
|
||||
static exportExpiringAssets(
|
||||
params?: ExpiringAssetQueryParams & { format?: 'xlsx' | 'csv' }
|
||||
): Promise<BaseResponse<CreateExportTaskResponse>> {
|
||||
return this.post<BaseResponse<CreateExportTaskResponse>>(
|
||||
'/api/admin/expiring-assets/export',
|
||||
params
|
||||
)
|
||||
}
|
||||
/**
|
||||
* 通过任意标识符查询设备或卡的完整详情
|
||||
* 支持虚拟号、ICCID、IMEI、SN、MSISDN
|
||||
|
||||
@@ -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 // 创建时间结束
|
||||
}
|
||||
|
||||
// 创建换货单请求
|
||||
|
||||
@@ -57,3 +57,5 @@ export { PackageTrafficAlertService } from './packageTrafficAlert'
|
||||
export { PollingPriorityQueueService } from './pollingPriorityQueue'
|
||||
|
||||
export { AssetWalletService } from './assetWallet'
|
||||
|
||||
export { OperationsReportsService } from './operationsReports'
|
||||
|
||||
65
src/api/modules/operationsReports.ts
Normal file
65
src/api/modules/operationsReports.ts
Normal file
@@ -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<ActivationSummaryApiResponse> {
|
||||
return this.get<ActivationSummaryApiResponse>(
|
||||
'/api/admin/operations-reports/activation-summary',
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
static getActivationTrend(
|
||||
params?: OperationsTrendQueryParams
|
||||
): Promise<ActivationTrendApiResponse> {
|
||||
return this.get<ActivationTrendApiResponse>(
|
||||
'/api/admin/operations-reports/activation-trend',
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
static exportActivationSummary(
|
||||
data: OperationsExportParams
|
||||
): Promise<OperationsExportTaskApiResponse> {
|
||||
return this.post<OperationsExportTaskApiResponse>(
|
||||
'/api/admin/operations-reports/activation-summary/export',
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
static getRenewalSummary(
|
||||
params?: OperationsReportQueryParams
|
||||
): Promise<RenewalSummaryApiResponse> {
|
||||
return this.get<RenewalSummaryApiResponse>(
|
||||
'/api/admin/operations-reports/package-renewal-summary',
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
static getRenewalTrend(params?: OperationsTrendQueryParams): Promise<RenewalTrendApiResponse> {
|
||||
return this.get<RenewalTrendApiResponse>(
|
||||
'/api/admin/operations-reports/package-renewal-trend',
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
static exportRenewalSummary(
|
||||
data: OperationsExportParams
|
||||
): Promise<OperationsExportTaskApiResponse> {
|
||||
return this.post<OperationsExportTaskApiResponse>(
|
||||
'/api/admin/operations-reports/package-renewal-summary/export',
|
||||
data
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,10 @@
|
||||
<div class="voucher-upload__file-item">
|
||||
<img
|
||||
v-if="isUploadImage(file)"
|
||||
class="voucher-upload__file-cover"
|
||||
class="voucher-upload__file-cover voucher-upload__file-cover--image"
|
||||
:src="getUploadFileUrl(file)"
|
||||
alt=""
|
||||
@click.stop="openImagePreview(file)"
|
||||
/>
|
||||
<div v-else class="voucher-upload__file-cover voucher-upload__file-cover--default">
|
||||
<ElIcon><Document /></ElIcon>
|
||||
@@ -47,6 +48,13 @@
|
||||
</div>
|
||||
</template>
|
||||
</ElUpload>
|
||||
<ElImageViewer
|
||||
v-if="previewVisible"
|
||||
:url-list="previewUrls"
|
||||
:initial-index="previewIndex"
|
||||
teleported
|
||||
@close="previewVisible = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -94,6 +102,7 @@
|
||||
|
||||
const rootRef = ref<HTMLElement>()
|
||||
const uploadRef = ref<UploadInstance>()
|
||||
const currentUploadFiles = ref<UploadFile[]>([])
|
||||
const uploadingCount = ref(0)
|
||||
const voucherFileKeyMap = new Map<number, string>()
|
||||
const voucherFileMetadataMap = new Map<number, RefundAttachment>()
|
||||
@@ -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<string[]>([])
|
||||
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;
|
||||
|
||||
@@ -87,8 +87,8 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
|
||||
},
|
||||
commission_record: {
|
||||
scene: 'commission_record',
|
||||
sceneName: '佣金记录',
|
||||
pageTitle: '导出佣金记录',
|
||||
sceneName: '佣金明细',
|
||||
pageTitle: '导出佣金明细',
|
||||
permissions: {
|
||||
detail: 'export_task:commission_record_detail',
|
||||
download: 'export_task:commission_record_download'
|
||||
@@ -96,12 +96,39 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
|
||||
},
|
||||
package_traffic_alert: {
|
||||
scene: 'package_traffic_alert',
|
||||
sceneName: '套餐真流量预警',
|
||||
pageTitle: '导出套餐真流量预警',
|
||||
sceneName: '套餐真流量达量预警',
|
||||
pageTitle: '导出套餐真流量达量预警',
|
||||
permissions: {
|
||||
detail: 'export_task:package_traffic_alert_detail',
|
||||
download: 'export_task:package_traffic_alert_download'
|
||||
}
|
||||
},
|
||||
expiring_asset: {
|
||||
scene: 'expiring_asset',
|
||||
sceneName: '临期资产',
|
||||
pageTitle: '导出临期资产',
|
||||
permissions: {
|
||||
detail: 'export_task:expiring_asset_detail',
|
||||
download: 'export_task:expiring_asset_download'
|
||||
}
|
||||
},
|
||||
operations_activation: {
|
||||
scene: 'operations_activation',
|
||||
sceneName: '设备激活情况报表',
|
||||
pageTitle: '导出设备激活情况报表',
|
||||
permissions: {
|
||||
detail: 'export_task:operations_activation_detail',
|
||||
download: 'export_task:operations_activation_download'
|
||||
}
|
||||
},
|
||||
operations_renewal: {
|
||||
scene: 'operations_renewal',
|
||||
sceneName: '套餐续费情况报表',
|
||||
pageTitle: '导出套餐续费情况报表',
|
||||
permissions: {
|
||||
detail: 'export_task:operations_renewal_detail',
|
||||
download: 'export_task:operations_renewal_download'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -501,6 +501,9 @@
|
||||
"exportExchange": "Export Exchanges",
|
||||
"exportCommissionRecord": "Export Commission Records",
|
||||
"exportPackageTrafficAlert": "Export Package Traffic Alerts",
|
||||
"exportExpiringAsset": "Export Expiring Assets",
|
||||
"exportOperationsActivation": "Export Activation Report",
|
||||
"exportOperationsRenewal": "Export Renewal Report",
|
||||
"exportTaskDetail": "Export Task Detail",
|
||||
"exchangeManagement": "Exchange Management",
|
||||
"exchangeDetail": "Exchange Order Detail",
|
||||
|
||||
@@ -428,8 +428,11 @@
|
||||
"exportRefund": "导出退款",
|
||||
"exportAgentRecharge": "导出代理充值",
|
||||
"exportExchange": "导出换货",
|
||||
"exportCommissionRecord": "导出佣金记录",
|
||||
"exportPackageTrafficAlert": "导出套餐真流量预警",
|
||||
"exportCommissionRecord": "导出佣金明细",
|
||||
"exportPackageTrafficAlert": "导出套餐真流量达量预警",
|
||||
"exportExpiringAsset": "导出临期资产",
|
||||
"exportOperationsActivation": "导出设备激活情况报表",
|
||||
"exportOperationsRenewal": "导出套餐续费情况报表",
|
||||
"exportTaskDetail": "导出任务详情",
|
||||
"exchangeManagement": "换货管理",
|
||||
"exchangeDetail": "换货单详情",
|
||||
|
||||
@@ -704,6 +704,36 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'export-expiring-asset',
|
||||
name: 'ExportExpiringAssetTaskList',
|
||||
component: RoutesAlias.ExportExpiringAssetTaskList,
|
||||
meta: {
|
||||
title: 'menus.assetManagement.exportExpiringAsset',
|
||||
exportTaskScene: 'expiring_asset',
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'export-operations-activation',
|
||||
name: 'ExportOperationsActivationTaskList',
|
||||
component: RoutesAlias.ExportOperationsActivationTaskList,
|
||||
meta: {
|
||||
title: 'menus.assetManagement.exportOperationsActivation',
|
||||
exportTaskScene: 'operations_activation',
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'export-operations-renewal',
|
||||
name: 'ExportOperationsRenewalTaskList',
|
||||
component: RoutesAlias.ExportOperationsRenewalTaskList,
|
||||
meta: {
|
||||
title: 'menus.assetManagement.exportOperationsRenewal',
|
||||
exportTaskScene: 'operations_renewal',
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'export-task-detail',
|
||||
name: 'ExportTaskDetail',
|
||||
@@ -1239,6 +1269,39 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 运营报表
|
||||
{
|
||||
path: '/operations-reports',
|
||||
name: 'OperationsReports',
|
||||
component: RoutesAlias.Home,
|
||||
meta: {
|
||||
title: '运营报表',
|
||||
icon: ''
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'activation',
|
||||
name: 'OperationsActivationReport',
|
||||
component: RoutesAlias.OperationsActivationReport,
|
||||
meta: {
|
||||
title: '设备激活报表',
|
||||
keepAlive: true,
|
||||
allowedUserTypes: [1, 2]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'renewal',
|
||||
name: 'OperationsRenewalReport',
|
||||
component: RoutesAlias.OperationsRenewalReport,
|
||||
meta: {
|
||||
title: '套餐续费报表',
|
||||
keepAlive: true,
|
||||
allowedUserTypes: [1, 2]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 组件中心
|
||||
|
||||
@@ -85,7 +85,10 @@ export enum RoutesAlias {
|
||||
ExportAgentRechargeTaskList = '/asset-management/export-task-management/export-agent-recharge', // 导出代理充值
|
||||
ExportExchangeTaskList = '/asset-management/export-task-management/export-exchange', // 导出换货
|
||||
ExportCommissionRecordTaskList = '/asset-management/export-task-management/export-commission-record', // 导出佣金记录
|
||||
ExportPackageTrafficAlertTaskList = '/asset-management/export-task-management/export-package-traffic-alert', // 导出套餐真流量预警
|
||||
ExportPackageTrafficAlertTaskList = '/asset-management/export-task-management/export-package-traffic-alert', // 导出套餐真流量达量预警
|
||||
ExportExpiringAssetTaskList = '/asset-management/export-task-management/export-expiring-asset', // 导出临期资产
|
||||
ExportOperationsActivationTaskList = '/asset-management/export-task-management/export-operations-activation', // 导出设备激活情况报表
|
||||
ExportOperationsRenewalTaskList = '/asset-management/export-task-management/export-operations-renewal', // 导出套餐续费情况报表
|
||||
ExportTaskDetail = '/asset-management/export-task-management/export-task-detail', // 导出任务详情
|
||||
|
||||
// 订单管理
|
||||
@@ -151,7 +154,11 @@ export enum RoutesAlias {
|
||||
// 手机号资产关联
|
||||
PhoneAssetAssociation = '/asset-management/phone-asset-association', // 手机号资产关联列表
|
||||
PhoneAssetUnbindImportTasks = '/asset-management/phone-asset-association/unbind-import-tasks', // 解绑导入任务列表
|
||||
PhoneAssetUnbindImportTaskDetail = '/asset-management/phone-asset-association/unbind-import-tasks/detail' // 解绑导入任务详情
|
||||
PhoneAssetUnbindImportTaskDetail = '/asset-management/phone-asset-association/unbind-import-tasks/detail', // 解绑导入任务详情
|
||||
|
||||
// 运营报表
|
||||
OperationsActivationReport = '/operations-reports/activation', // 设备激活报表
|
||||
OperationsRenewalReport = '/operations-reports/renewal' // 套餐续费报表
|
||||
}
|
||||
|
||||
// 主页路由 - 修改为资产信息页面
|
||||
|
||||
@@ -77,8 +77,8 @@ export interface AgentRechargeQueryParams {
|
||||
shop_id?: number
|
||||
status?: AgentRechargeStatus
|
||||
recharge_source?: AgentRechargeSource
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
dateRange?: string[] // For date range picker in UI
|
||||
}
|
||||
|
||||
|
||||
@@ -120,8 +120,8 @@ export interface ExpiringAssetQueryParams {
|
||||
package_id?: number
|
||||
days_min?: number
|
||||
days_max?: number
|
||||
expires_from?: string
|
||||
expires_to?: string
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
@@ -507,8 +507,8 @@ export interface AssetAllocationRecordQueryParams extends PaginationParams {
|
||||
from_shop_id?: number // 来源店铺ID
|
||||
to_shop_id?: number // 目标店铺ID
|
||||
operator_id?: number // 操作人ID
|
||||
created_at_start?: string // 创建时间起始
|
||||
created_at_end?: string // 创建时间结束
|
||||
start_time?: string // 创建时间起始
|
||||
end_time?: string // 创建时间结束
|
||||
}
|
||||
|
||||
// 资产分配记录
|
||||
|
||||
@@ -11,6 +11,9 @@ export type ExportTaskScene =
|
||||
| 'exchange'
|
||||
| 'commission_record'
|
||||
| 'package_traffic_alert'
|
||||
| 'expiring_asset'
|
||||
| 'operations_activation'
|
||||
| 'operations_renewal'
|
||||
|
||||
export type ExportTaskFormat = 'xlsx' | 'csv'
|
||||
|
||||
|
||||
@@ -153,3 +153,6 @@ export * from './pollingPriorityQueue'
|
||||
|
||||
// 资产钱包自动续费相关
|
||||
export * from './assetWallet'
|
||||
|
||||
// 运营报表相关
|
||||
export * from './operationsReport'
|
||||
|
||||
131
src/types/api/operationsReport.ts
Normal file
131
src/types/api/operationsReport.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { BaseResponse } from './common'
|
||||
|
||||
export type OperationsActivationGroupBy =
|
||||
| 'device_name'
|
||||
| 'device_model'
|
||||
| 'manufacturer'
|
||||
| 'business_user_group'
|
||||
| 'agent'
|
||||
| 'shop'
|
||||
| 'business_owner'
|
||||
|
||||
export type OperationsRenewalGroupBy =
|
||||
| 'package_series'
|
||||
| 'package_name'
|
||||
| 'business_user_group'
|
||||
| 'agent'
|
||||
| 'shop'
|
||||
| 'business_owner'
|
||||
|
||||
export type OperationsGroupBy = OperationsActivationGroupBy | OperationsRenewalGroupBy | ''
|
||||
|
||||
export type OperationsTrendGranularity = 'day' | 'month'
|
||||
|
||||
export interface OperationsReportQueryParams {
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
group_by?: string
|
||||
}
|
||||
|
||||
export interface OperationsTrendQueryParams extends OperationsReportQueryParams {
|
||||
granularity?: OperationsTrendGranularity
|
||||
}
|
||||
|
||||
export interface OperationsExportParams extends OperationsReportQueryParams {
|
||||
format: 'xlsx' | 'csv'
|
||||
}
|
||||
|
||||
export interface OperationsExportTaskInfo {
|
||||
task_id: number
|
||||
task_no: string
|
||||
status: number
|
||||
status_name: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ActivationSummaryItem {
|
||||
group_key?: string
|
||||
group_name?: 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
|
||||
avg_usage_per_card_mb?: number | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ActivationSummaryTotals extends Partial<ActivationSummaryItem> {}
|
||||
|
||||
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<RenewalSummaryItem> {}
|
||||
|
||||
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<ActivationSummaryData>
|
||||
export type ActivationTrendApiResponse = BaseResponse<ActivationTrendData>
|
||||
export type RenewalSummaryApiResponse = BaseResponse<RenewalSummaryData>
|
||||
export type RenewalTrendApiResponse = BaseResponse<RenewalTrendData>
|
||||
export type OperationsExportTaskApiResponse = BaseResponse<OperationsExportTaskInfo>
|
||||
1
src/types/components.d.ts
vendored
1
src/types/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,13 @@
|
||||
临期资产 {{ summary.total_count }} 条(卡 {{ summary.card_count }},设备
|
||||
{{ summary.device_count }}),0-3 天资产优先显示。
|
||||
</div>
|
||||
<ElTag v-if="exportTask" :type="exportStatusType" class="export-status-tag">
|
||||
{{ exportTask.status_name || '处理中' }}
|
||||
</ElTag>
|
||||
<ElButton v-if="canDownload" type="success" link @click="downloadExport">
|
||||
下载
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="exporting" @click="handleExport">导出</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
@@ -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<ShopResponse[]>([])
|
||||
@@ -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<ExportTaskDetail>({
|
||||
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 @@
|
||||
<style scoped lang="scss">
|
||||
.expiring-assets-page {
|
||||
.page-intro {
|
||||
display: inline-block;
|
||||
margin-right: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.export-status-tag {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.expiry-critical,
|
||||
.expiry-warning,
|
||||
.expiry-notice {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportExpiringAssetTaskList' })
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportOperationsActivationTaskList' })
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportOperationsRenewalTaskList' })
|
||||
</script>
|
||||
@@ -15,7 +15,7 @@
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #actions>
|
||||
<template #left>
|
||||
<ElButton
|
||||
type="danger"
|
||||
plain
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #actions>
|
||||
<template #left>
|
||||
<ElButton @click="goAssociationList">返回关联列表</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
@@ -77,8 +77,8 @@
|
||||
to_shop_id: undefined as number | undefined,
|
||||
operator_id: undefined as number | undefined,
|
||||
dateRange: [] as string[],
|
||||
created_at_start: '',
|
||||
created_at_end: ''
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
}
|
||||
|
||||
// 搜索表单
|
||||
@@ -427,11 +427,11 @@
|
||||
const handleSearch = () => {
|
||||
// 处理日期范围
|
||||
if (formFilters.dateRange && Array.isArray(formFilters.dateRange)) {
|
||||
formFilters.created_at_start = formFilters.dateRange[0] || ''
|
||||
formFilters.created_at_end = formFilters.dateRange[1] || ''
|
||||
formFilters.start_time = formFilters.dateRange[0] || ''
|
||||
formFilters.end_time = formFilters.dateRange[1] || ''
|
||||
} else {
|
||||
formFilters.created_at_start = ''
|
||||
formFilters.created_at_end = ''
|
||||
formFilters.start_time = ''
|
||||
formFilters.end_time = ''
|
||||
}
|
||||
pagination.page = 1
|
||||
getTableData()
|
||||
|
||||
@@ -2,191 +2,217 @@
|
||||
<div class="my-commission-page">
|
||||
<!-- 标签页 -->
|
||||
<ElCard shadow="never">
|
||||
<ElTabs v-model="activeTab">
|
||||
<!-- 佣金明细 -->
|
||||
<ElTabPane label="佣金明细" name="commission">
|
||||
<!-- 搜索栏 -->
|
||||
<ArtSearchBar
|
||||
v-model:filter="commissionSearchForm"
|
||||
:items="commissionSearchItems"
|
||||
show-expand
|
||||
@reset="handleCommissionReset"
|
||||
@search="handleCommissionSearch"
|
||||
/>
|
||||
<div class="commission-tabs">
|
||||
<ElTabs v-model="activeTab">
|
||||
<!-- 佣金明细 -->
|
||||
<ElTabPane label="佣金明细" name="commission">
|
||||
<!-- 搜索栏 -->
|
||||
<ArtSearchBar
|
||||
v-model:filter="commissionSearchForm"
|
||||
:items="commissionSearchItems"
|
||||
show-expand
|
||||
@reset="handleCommissionReset"
|
||||
@search="handleCommissionSearch"
|
||||
/>
|
||||
|
||||
<!-- 表格头部 -->
|
||||
<ArtTableHeader
|
||||
:columnList="commissionColumnOptions"
|
||||
v-model:columns="commissionColumnChecks"
|
||||
@refresh="handleCommissionRefresh"
|
||||
style="margin-top: 20px"
|
||||
<!-- 表格头部 -->
|
||||
<ArtTableHeader
|
||||
:columnList="commissionColumnOptions"
|
||||
v-model:columns="commissionColumnChecks"
|
||||
@refresh="handleCommissionRefresh"
|
||||
style="margin-top: 20px"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="showWithdrawalDialog"
|
||||
v-permission="'my_commission:add'"
|
||||
>发起提现</ElButton
|
||||
>
|
||||
<ElButton
|
||||
v-permission="'commission_record:export'"
|
||||
@click="exportDialogVisible = true"
|
||||
>导出</ElButton
|
||||
>
|
||||
|
||||
<div class="commission-summary">
|
||||
<div class="commission-summary-item">
|
||||
<span class="summary-label">总佣金</span>
|
||||
<strong class="summary-value">{{
|
||||
formatMoney(summary.total_commission, false)
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="commission-summary-item">
|
||||
<span class="summary-label">可提现</span>
|
||||
<strong class="summary-value is-success">{{
|
||||
formatMoney(summary.available_commission, false)
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="commission-summary-item">
|
||||
<span class="summary-label">冻结中</span>
|
||||
<strong class="summary-value">{{
|
||||
formatMoney(summary.frozen_commission, false)
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="commission-summary-item">
|
||||
<span class="summary-label">提现中</span>
|
||||
<strong class="summary-value is-warning">{{
|
||||
formatMoney(summary.withdrawing_commission, false)
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="commission-summary-item">
|
||||
<span class="summary-label">已提现</span>
|
||||
<strong class="summary-value">{{
|
||||
formatMoney(summary.withdrawn_commission, false)
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="commission-summary-item">
|
||||
<span class="summary-label">未提现</span>
|
||||
<strong class="summary-value">{{
|
||||
formatMoney(summary.unwithdraw_commission, false)
|
||||
}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<!-- 表格 -->
|
||||
<ArtTable
|
||||
ref="commissionTableRef"
|
||||
:row-key="getCommissionRowKey"
|
||||
:loading="commissionLoading"
|
||||
:data="commissionList"
|
||||
:currentPage="commissionPagination.page"
|
||||
:pageSize="commissionPagination.pageSize"
|
||||
:total="commissionPagination.total"
|
||||
:marginTop="10"
|
||||
:height="420"
|
||||
@size-change="handleCommissionSizeChange"
|
||||
@current-change="handleCommissionCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn
|
||||
v-for="col in commissionColumns"
|
||||
:key="col.prop || col.type"
|
||||
v-bind="col"
|
||||
/>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- 提现记录 -->
|
||||
<ElTabPane label="提现记录" name="withdrawal">
|
||||
<!-- 搜索栏 -->
|
||||
<ArtSearchBar
|
||||
v-model:filter="withdrawalSearchForm"
|
||||
:items="withdrawalSearchItems"
|
||||
:show-expand="false"
|
||||
@reset="handleWithdrawalReset"
|
||||
@search="handleWithdrawalSearch"
|
||||
/>
|
||||
|
||||
<!-- 表格头部 -->
|
||||
<ArtTableHeader
|
||||
:columnList="withdrawalColumnOptions"
|
||||
v-model:columns="withdrawalColumnChecks"
|
||||
@refresh="handleWithdrawalRefresh"
|
||||
style="margin-top: 20px"
|
||||
/>
|
||||
|
||||
<!-- 表格 -->
|
||||
<ArtTable
|
||||
ref="withdrawalTableRef"
|
||||
row-key="id"
|
||||
:loading="withdrawalLoading"
|
||||
:data="withdrawalList"
|
||||
:currentPage="withdrawalPagination.page"
|
||||
:pageSize="withdrawalPagination.pageSize"
|
||||
:total="withdrawalPagination.total"
|
||||
:actions="getWithdrawalActions"
|
||||
:actionsWidth="150"
|
||||
:marginTop="10"
|
||||
:height="500"
|
||||
@size-change="handleWithdrawalSizeChange"
|
||||
@current-change="handleWithdrawalCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn
|
||||
v-for="col in withdrawalColumns"
|
||||
:key="col.prop || col.type"
|
||||
v-bind="col"
|
||||
/>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElTabPane>
|
||||
<!-- 提现资料资格 -->
|
||||
<ElTabPane label="提现资料" name="qualification">
|
||||
<ArtTableHeader
|
||||
:columnList="qualificationColumnOptions"
|
||||
v-model:columns="qualificationColumnChecks"
|
||||
@refresh="getQualificationList"
|
||||
style="margin-top: 20px"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
type="primary"
|
||||
v-permission="'my_commission:add'"
|
||||
@click="showQualificationDialog"
|
||||
>
|
||||
提交/更新资料
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
ref="qualificationTableRef"
|
||||
row-key="id"
|
||||
:loading="qualificationLoading"
|
||||
:data="qualificationList"
|
||||
:currentPage="qualificationPagination.page"
|
||||
:pageSize="qualificationPagination.pageSize"
|
||||
:total="qualificationPagination.total"
|
||||
:actions="getQualificationActions"
|
||||
:actionsWidth="100"
|
||||
:marginTop="10"
|
||||
:height="500"
|
||||
@size-change="handleQualificationSizeChange"
|
||||
@current-change="handleQualificationCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn
|
||||
v-for="col in qualificationColumns"
|
||||
:key="col.prop || col.type"
|
||||
v-bind="col"
|
||||
/>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
<!-- 超级管理员店铺选择 -->
|
||||
<div v-if="isSuperAdmin" class="shop-selector">
|
||||
<span class="shop-selector__label">店铺</span>
|
||||
<ElSelect
|
||||
v-model="selectedShopId"
|
||||
placeholder="请选择店铺"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
clearable
|
||||
:loading="shopSearchLoading"
|
||||
:remote-method="searchShops"
|
||||
style="width: 280px"
|
||||
@change="handleShopChange"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="showWithdrawalDialog"
|
||||
v-permission="'my_commission:add'"
|
||||
>发起提现</ElButton
|
||||
>
|
||||
<ElButton
|
||||
v-permission="'commission_record:export'"
|
||||
@click="exportDialogVisible = true"
|
||||
>导出</ElButton
|
||||
>
|
||||
|
||||
<div class="commission-summary">
|
||||
<div class="commission-summary-item">
|
||||
<span class="summary-label">总佣金</span>
|
||||
<strong class="summary-value">{{
|
||||
formatMoney(summary.total_commission, false)
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="commission-summary-item">
|
||||
<span class="summary-label">可提现</span>
|
||||
<strong class="summary-value is-success">{{
|
||||
formatMoney(summary.available_commission, false)
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="commission-summary-item">
|
||||
<span class="summary-label">冻结中</span>
|
||||
<strong class="summary-value">{{
|
||||
formatMoney(summary.frozen_commission, false)
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="commission-summary-item">
|
||||
<span class="summary-label">提现中</span>
|
||||
<strong class="summary-value is-warning">{{
|
||||
formatMoney(summary.withdrawing_commission, false)
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="commission-summary-item">
|
||||
<span class="summary-label">已提现</span>
|
||||
<strong class="summary-value">{{
|
||||
formatMoney(summary.withdrawn_commission, false)
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="commission-summary-item">
|
||||
<span class="summary-label">未提现</span>
|
||||
<strong class="summary-value">{{
|
||||
formatMoney(summary.unwithdraw_commission, false)
|
||||
}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<!-- 表格 -->
|
||||
<ArtTable
|
||||
ref="commissionTableRef"
|
||||
:row-key="getCommissionRowKey"
|
||||
:loading="commissionLoading"
|
||||
:data="commissionList"
|
||||
:currentPage="commissionPagination.page"
|
||||
:pageSize="commissionPagination.pageSize"
|
||||
:total="commissionPagination.total"
|
||||
:marginTop="10"
|
||||
:height="420"
|
||||
@size-change="handleCommissionSizeChange"
|
||||
@current-change="handleCommissionCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn
|
||||
v-for="col in commissionColumns"
|
||||
:key="col.prop || col.type"
|
||||
v-bind="col"
|
||||
/>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- 提现记录 -->
|
||||
<ElTabPane label="提现记录" name="withdrawal">
|
||||
<!-- 搜索栏 -->
|
||||
<ArtSearchBar
|
||||
v-model:filter="withdrawalSearchForm"
|
||||
:items="withdrawalSearchItems"
|
||||
:show-expand="false"
|
||||
@reset="handleWithdrawalReset"
|
||||
@search="handleWithdrawalSearch"
|
||||
/>
|
||||
|
||||
<!-- 表格头部 -->
|
||||
<ArtTableHeader
|
||||
:columnList="withdrawalColumnOptions"
|
||||
v-model:columns="withdrawalColumnChecks"
|
||||
@refresh="handleWithdrawalRefresh"
|
||||
style="margin-top: 20px"
|
||||
/>
|
||||
|
||||
<!-- 表格 -->
|
||||
<ArtTable
|
||||
ref="withdrawalTableRef"
|
||||
row-key="id"
|
||||
:loading="withdrawalLoading"
|
||||
:data="withdrawalList"
|
||||
:currentPage="withdrawalPagination.page"
|
||||
:pageSize="withdrawalPagination.pageSize"
|
||||
:total="withdrawalPagination.total"
|
||||
:actions="getWithdrawalActions"
|
||||
:actionsWidth="150"
|
||||
:marginTop="10"
|
||||
:height="500"
|
||||
@size-change="handleWithdrawalSizeChange"
|
||||
@current-change="handleWithdrawalCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn
|
||||
v-for="col in withdrawalColumns"
|
||||
:key="col.prop || col.type"
|
||||
v-bind="col"
|
||||
/>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElTabPane>
|
||||
<!-- 提现资料资格 -->
|
||||
<ElTabPane label="提现资料" name="qualification">
|
||||
<ArtTableHeader
|
||||
:columnList="qualificationColumnOptions"
|
||||
v-model:columns="qualificationColumnChecks"
|
||||
@refresh="getQualificationList"
|
||||
style="margin-top: 20px"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
type="primary"
|
||||
v-permission="'my_commission:add'"
|
||||
@click="showQualificationDialog"
|
||||
>
|
||||
提交/更新资料
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
ref="qualificationTableRef"
|
||||
row-key="id"
|
||||
:loading="qualificationLoading"
|
||||
:data="qualificationList"
|
||||
:currentPage="qualificationPagination.page"
|
||||
:pageSize="qualificationPagination.pageSize"
|
||||
:total="qualificationPagination.total"
|
||||
:actions="getQualificationActions"
|
||||
:actionsWidth="100"
|
||||
:marginTop="10"
|
||||
:height="500"
|
||||
@size-change="handleQualificationSizeChange"
|
||||
@current-change="handleQualificationCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn
|
||||
v-for="col in qualificationColumns"
|
||||
:key="col.prop || col.type"
|
||||
v-bind="col"
|
||||
/>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
<ElOption
|
||||
v-for="shop in shopOptions"
|
||||
:key="shop.id"
|
||||
:label="shop.shop_name"
|
||||
:value="shop.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
<span v-if="!selectedShopId" class="shop-selector__tip">请选择店铺后查看佣金数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- 发起提现对话框 -->
|
||||
@@ -531,7 +557,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h } from 'vue'
|
||||
import { CommissionService, OrderService } from '@/api/modules'
|
||||
import { CommissionService, OrderService, ShopService } from '@/api/modules'
|
||||
import { ElButton, ElMessage, ElMessageBox, ElTag } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import type {
|
||||
@@ -568,12 +594,45 @@
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 获取当前用户的 shop_id
|
||||
const currentShopId = computed(() => userStore.info?.shop_id)
|
||||
const isSuperAdmin = computed(() => userStore.isSuperAdmin)
|
||||
|
||||
// 超级管理员通过顶部下拉选择店铺,其余账号使用自身 shop_id
|
||||
const selectedShopId = ref<number | undefined>(undefined)
|
||||
const currentShopId = computed(() =>
|
||||
isSuperAdmin.value ? selectedShopId.value : userStore.info?.shop_id
|
||||
)
|
||||
|
||||
// 如果没有 shop_id,显示提示
|
||||
const hasShopId = computed(() => !!currentShopId.value)
|
||||
|
||||
// 超级管理员店铺下拉
|
||||
const shopOptions = ref<Array<{ id: number; shop_name: string }>>([])
|
||||
const shopSearchLoading = ref(false)
|
||||
let shopSearchRequestId = 0
|
||||
|
||||
const searchShops = async (query: string) => {
|
||||
const requestId = ++shopSearchRequestId
|
||||
shopSearchLoading.value = true
|
||||
try {
|
||||
const params: { page: number; page_size: number; shop_name?: string } = {
|
||||
page: 1,
|
||||
page_size: 20
|
||||
}
|
||||
const keyword = query.trim()
|
||||
if (keyword) params.shop_name = keyword
|
||||
const res = await ShopService.getShops(params)
|
||||
if (res.code === 0 && requestId === shopSearchRequestId) {
|
||||
shopOptions.value = res.data.items || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search shops failed:', error)
|
||||
} finally {
|
||||
if (requestId === shopSearchRequestId) {
|
||||
shopSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标签页
|
||||
const activeTab = ref('commission')
|
||||
|
||||
@@ -879,7 +938,7 @@
|
||||
// 获取佣金明细
|
||||
const getCommissionList = async () => {
|
||||
if (!currentShopId.value) {
|
||||
ElMessage.warning('未找到店铺信息')
|
||||
if (!isSuperAdmin.value) ElMessage.warning('未找到店铺信息')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1086,7 +1145,7 @@
|
||||
// 获取提现记录
|
||||
const getWithdrawalList = async () => {
|
||||
if (!currentShopId.value) {
|
||||
ElMessage.warning('未找到店铺信息')
|
||||
if (!isSuperAdmin.value) ElMessage.warning('未找到店铺信息')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1237,7 +1296,7 @@
|
||||
const handleSubmitWithdrawal = async () => {
|
||||
if (!withdrawalFormRef.value) return
|
||||
if (!currentShopId.value) {
|
||||
ElMessage.warning('未找到店铺信息')
|
||||
ElMessage.warning(isSuperAdmin.value ? '请先选择店铺' : '未找到店铺信息')
|
||||
return
|
||||
}
|
||||
if (isWithdrawalAmountDisabled.value) {
|
||||
@@ -1321,17 +1380,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 监听标签页切换
|
||||
watch(activeTab, (newTab) => {
|
||||
if (newTab === 'commission') {
|
||||
// 刷新当前标签页数据
|
||||
const refreshActiveTab = () => {
|
||||
if (activeTab.value === 'commission') {
|
||||
getCommissionList()
|
||||
} else if (newTab === 'withdrawal') {
|
||||
} else if (activeTab.value === 'withdrawal') {
|
||||
getWithdrawalList()
|
||||
} else if (newTab === 'qualification') {
|
||||
} else if (activeTab.value === 'qualification') {
|
||||
getQualificationList()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听标签页切换
|
||||
watch(activeTab, () => {
|
||||
if (!currentShopId.value) return
|
||||
refreshActiveTab()
|
||||
})
|
||||
|
||||
// 切换店铺:重置分页并刷新概览与当前标签页
|
||||
const handleShopChange = () => {
|
||||
commissionPagination.page = 1
|
||||
withdrawalPagination.page = 1
|
||||
qualificationPagination.page = 1
|
||||
if (!currentShopId.value) {
|
||||
summary.value = {
|
||||
main_balance: 0,
|
||||
total_commission: 0,
|
||||
available_commission: 0,
|
||||
frozen_commission: 0,
|
||||
withdrawing_commission: 0,
|
||||
withdrawn_commission: 0,
|
||||
unwithdraw_commission: 0
|
||||
}
|
||||
commissionList.value = []
|
||||
withdrawalList.value = []
|
||||
qualificationList.value = []
|
||||
commissionPagination.total = 0
|
||||
withdrawalPagination.total = 0
|
||||
qualificationPagination.total = 0
|
||||
return
|
||||
}
|
||||
loadSummary()
|
||||
refreshActiveTab()
|
||||
}
|
||||
|
||||
// ==================== 提现资料资格 ====================
|
||||
|
||||
const qualificationLoading = ref(false)
|
||||
@@ -1491,7 +1583,7 @@
|
||||
const handleSubmitQualification = async () => {
|
||||
if (!qualificationFormRef.value) return
|
||||
if (!currentShopId.value) {
|
||||
ElMessage.warning('未找到店铺信息')
|
||||
ElMessage.warning(isSuperAdmin.value ? '请先选择店铺' : '未找到店铺信息')
|
||||
return
|
||||
}
|
||||
await qualificationFormRef.value.validate(async (valid) => {
|
||||
@@ -1738,6 +1830,11 @@
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isSuperAdmin.value) {
|
||||
searchShops('')
|
||||
searchCommissionOrders('')
|
||||
return
|
||||
}
|
||||
if (!hasShopId.value) {
|
||||
ElMessage.warning('当前账号未关联店铺,无法查看佣金信息')
|
||||
return
|
||||
@@ -1749,6 +1846,34 @@
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.commission-tabs {
|
||||
position: relative;
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
padding-right: 480px;
|
||||
}
|
||||
}
|
||||
|
||||
.shop-selector {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
|
||||
&__label {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
&__tip {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.qualification-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -1772,6 +1897,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.commission-summary {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<CommissionStats v-if="hasPermission('dashboard_analysis:commission_stats')" />
|
||||
<WithdrawalSettings v-if="hasPermission('dashboard_analysis:withdrawal_settings')" />
|
||||
<ActivePaymentSettings v-if="hasPermission('dashboard_analysis:payment_settings')" />
|
||||
<OperationsActivationTrend v-if="isPlatformAccount" />
|
||||
<OperationsRenewalTrend v-if="isPlatformAccount" />
|
||||
|
||||
<!--<el-row :gutter="20">-->
|
||||
<!-- <el-col :xl="14" :lg="15" :xs="24">-->
|
||||
@@ -47,11 +49,13 @@
|
||||
import CommissionStats from './widget/CommissionStats.vue'
|
||||
import WithdrawalSettings from './widget/WithdrawalSettings.vue'
|
||||
import ActivePaymentSettings from './widget/ActivePaymentSettings.vue'
|
||||
import OperationsActivationTrend from './widget/OperationsActivationTrend.vue'
|
||||
import OperationsRenewalTrend from './widget/OperationsRenewalTrend.vue'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
|
||||
defineOptions({ name: 'Analysis' })
|
||||
|
||||
const { hasPermission } = usePermission()
|
||||
const { hasPermission, isPlatformAccount } = usePermission()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<ElCard shadow="never" class="operations-trend-widget">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<span class="header-title">设备激活趋势</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<ElDatePicker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
:clearable="false"
|
||||
class="range-picker"
|
||||
/>
|
||||
<ElSelect v-model="granularity" class="granularity-select">
|
||||
<ElOption
|
||||
v-for="opt in GRANULARITY_OPTIONS"
|
||||
:key="String(opt.value)"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElButton size="small" @click="loadTrend" :loading="loading">刷新</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<OperationsTrendChart :categories="categories" :series="series" height="22rem" />
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { OperationsReportsService } from '@/api/modules'
|
||||
import type { ActivationTrendData } from '@/types/api'
|
||||
import {
|
||||
ACTIVATION_TREND_SERIES,
|
||||
GRANULARITY_OPTIONS,
|
||||
normalizeRatio,
|
||||
type OperationsTrendSeries
|
||||
} from '@/views/operations-reports/constants'
|
||||
import OperationsTrendChart from '@/views/operations-reports/components/OperationsTrendChart.vue'
|
||||
|
||||
defineOptions({ name: 'OperationsActivationTrend' })
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
|
||||
const formatDateTimeValue = (date: Date) => {
|
||||
const offset = -date.getTimezoneOffset()
|
||||
const sign = offset >= 0 ? '+' : '-'
|
||||
const abs = Math.abs(offset)
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
|
||||
date.getHours()
|
||||
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${pad(
|
||||
Math.floor(abs / 60)
|
||||
)}:${pad(abs % 60)}`
|
||||
}
|
||||
|
||||
const getDefaultRange = () => {
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - 29 * 24 * 60 * 60 * 1000)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return [formatDateTimeValue(start), formatDateTimeValue(end)]
|
||||
}
|
||||
|
||||
const dateRange = ref<string[]>(getDefaultRange())
|
||||
const granularity = ref<'day' | 'month'>('day')
|
||||
const trend = ref<ActivationTrendData | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const categories = computed(() => trend.value?.points?.map((item) => item.period) ?? [])
|
||||
|
||||
const series = computed<OperationsTrendSeries[]>(() => {
|
||||
const points = trend.value?.points ?? []
|
||||
return ACTIVATION_TREND_SERIES.map((config) => ({
|
||||
...config,
|
||||
data: points.map((point) => {
|
||||
const raw = point[config.key]
|
||||
if (config.yAxisIndex === 1) return normalizeRatio(raw)
|
||||
const num = Number(raw)
|
||||
return Number.isNaN(num) ? null : num
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
const loadTrend = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await OperationsReportsService.getActivationTrend({
|
||||
start_time: dateRange.value?.[0],
|
||||
end_time: dateRange.value?.[1],
|
||||
granularity: granularity.value
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
trend.value = res.data
|
||||
} else {
|
||||
trend.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取设备激活趋势失败:', error)
|
||||
trend.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(granularity, loadTrend)
|
||||
|
||||
onMounted(loadTrend)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.operations-trend-widget {
|
||||
:deep(.el-card__header) {
|
||||
padding: 14px 20px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
|
||||
.header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.range-picker {
|
||||
width: 380px;
|
||||
}
|
||||
|
||||
.granularity-select {
|
||||
width: 110px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.operations-trend-widget {
|
||||
.card-header {
|
||||
.header-left,
|
||||
.header-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.range-picker {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
180
src/views/dashboard/analysis/widget/OperationsRenewalTrend.vue
Normal file
180
src/views/dashboard/analysis/widget/OperationsRenewalTrend.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<ElCard shadow="never" class="operations-trend-widget">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<span class="header-title">套餐续费趋势</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<ElDatePicker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
:clearable="false"
|
||||
class="range-picker"
|
||||
/>
|
||||
<ElSelect v-model="granularity" class="granularity-select">
|
||||
<ElOption
|
||||
v-for="opt in GRANULARITY_OPTIONS"
|
||||
:key="String(opt.value)"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElButton size="small" @click="loadTrend" :loading="loading">刷新</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<OperationsTrendChart :categories="categories" :series="series" height="22rem" />
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { OperationsReportsService } from '@/api/modules'
|
||||
import type { RenewalTrendData } from '@/types/api'
|
||||
import {
|
||||
GRANULARITY_OPTIONS,
|
||||
RENEWAL_TREND_SERIES,
|
||||
normalizeRatio,
|
||||
type OperationsTrendSeries
|
||||
} from '@/views/operations-reports/constants'
|
||||
import OperationsTrendChart from '@/views/operations-reports/components/OperationsTrendChart.vue'
|
||||
|
||||
defineOptions({ name: 'OperationsRenewalTrend' })
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
|
||||
const formatDateTimeValue = (date: Date) => {
|
||||
const offset = -date.getTimezoneOffset()
|
||||
const sign = offset >= 0 ? '+' : '-'
|
||||
const abs = Math.abs(offset)
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
|
||||
date.getHours()
|
||||
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${pad(
|
||||
Math.floor(abs / 60)
|
||||
)}:${pad(abs % 60)}`
|
||||
}
|
||||
|
||||
const getDefaultRange = () => {
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - 29 * 24 * 60 * 60 * 1000)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return [formatDateTimeValue(start), formatDateTimeValue(end)]
|
||||
}
|
||||
|
||||
const dateRange = ref<string[]>(getDefaultRange())
|
||||
const granularity = ref<'day' | 'month'>('day')
|
||||
const trend = ref<RenewalTrendData | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const categories = computed(() => trend.value?.points?.map((item) => item.period) ?? [])
|
||||
|
||||
const series = computed<OperationsTrendSeries[]>(() => {
|
||||
const points = trend.value?.points ?? []
|
||||
return RENEWAL_TREND_SERIES.map((config) => ({
|
||||
...config,
|
||||
data: points.map((point) => {
|
||||
const raw = point[config.key]
|
||||
if (config.yAxisIndex === 1) return normalizeRatio(raw)
|
||||
const num = Number(raw)
|
||||
return Number.isNaN(num) ? null : num
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
const loadTrend = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await OperationsReportsService.getRenewalTrend({
|
||||
start_time: dateRange.value?.[0],
|
||||
end_time: dateRange.value?.[1],
|
||||
granularity: granularity.value
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
trend.value = res.data
|
||||
} else {
|
||||
trend.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取套餐续费趋势失败:', error)
|
||||
trend.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(granularity, loadTrend)
|
||||
|
||||
onMounted(loadTrend)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.operations-trend-widget {
|
||||
:deep(.el-card__header) {
|
||||
padding: 14px 20px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
|
||||
.header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.range-picker {
|
||||
width: 380px;
|
||||
}
|
||||
|
||||
.granularity-select {
|
||||
width: 110px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.operations-trend-widget {
|
||||
.card-header {
|
||||
.header-left,
|
||||
.header-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.range-picker {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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]
|
||||
}))
|
||||
|
||||
// 刷新表格
|
||||
|
||||
@@ -1,51 +1,164 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
<ElDrawer
|
||||
:model-value="modelValue"
|
||||
:title="dialogTitle"
|
||||
width="760px"
|
||||
direction="rtl"
|
||||
size="860px"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="140px">
|
||||
<ElFormItem label="付款凭证" prop="payment_voucher_keys">
|
||||
<VoucherUpload
|
||||
ref="uploadRef"
|
||||
v-model="form.payment_voucher_keys"
|
||||
voucher-name="付款凭证"
|
||||
:max-count="5"
|
||||
@uploading-change="voucherUploading = $event"
|
||||
@change="handleVoucherChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="外部交易流水号" prop="external_transaction_no">
|
||||
<div class="external-transaction-row">
|
||||
<ElInput
|
||||
v-model="form.external_transaction_no"
|
||||
maxlength="128"
|
||||
show-word-limit
|
||||
placeholder="请输入经人工核对的外部交易流水号,或点击右侧识别凭证预填"
|
||||
:disabled="ocrLoading"
|
||||
/>
|
||||
<ElButton
|
||||
:loading="ocrLoading"
|
||||
:disabled="!voucherKeys.length"
|
||||
@click="handleRecognizeVoucher"
|
||||
>
|
||||
识别凭证
|
||||
</ElButton>
|
||||
</div>
|
||||
<div class="external-transaction-tip"> 识别结果仅供参考,请对照凭证核对后再提交。 </div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="收款方式" prop="payment_method_id">
|
||||
<ElSelect
|
||||
v-model="form.payment_method_id"
|
||||
placeholder="请选择收款方式"
|
||||
style="width: 100%"
|
||||
:loading="paymentMethodsLoading"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
:remote-method="searchPaymentMethods"
|
||||
@visible-change="handlePaymentMethodVisible"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in enabledPaymentMethods"
|
||||
v-for="item in paymentMethods"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
<div v-if="!paymentMethodsLoading && !enabledPaymentMethodCount" class="form-tip">
|
||||
<div v-if="!paymentMethodsLoading && !paymentMethods.length" class="form-tip">
|
||||
暂无可用的收款方式,请联系管理员
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="核销账单" required>
|
||||
<div class="bill-selector">
|
||||
<div class="bill-selector__filters">
|
||||
<div class="bill-selector__row">
|
||||
<ElSelect
|
||||
v-model="billSearch.source_type"
|
||||
placeholder="来源"
|
||||
clearable
|
||||
class="bill-selector__filter"
|
||||
>
|
||||
<ElOption
|
||||
v-for="opt in BILL_SOURCE_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElInput
|
||||
v-model="billSearch.source_no"
|
||||
placeholder="来源单号"
|
||||
clearable
|
||||
class="bill-selector__filter"
|
||||
/>
|
||||
<ElSelect
|
||||
v-model="billSearch.status"
|
||||
placeholder="核销状态"
|
||||
clearable
|
||||
class="bill-selector__filter"
|
||||
>
|
||||
<ElOption
|
||||
v-for="opt in BILL_STATUS_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
<div class="bill-selector__row">
|
||||
<ElSelect
|
||||
v-model="billSearch.customer_id"
|
||||
placeholder="店铺"
|
||||
clearable
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
:remote-method="searchBillShops"
|
||||
class="bill-selector__filter"
|
||||
>
|
||||
<ElOption
|
||||
v-for="shop in shopOptions"
|
||||
:key="shop.id"
|
||||
:label="shop.shop_name"
|
||||
:value="shop.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElDatePicker
|
||||
v-model="billSearch.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="bill-selector__filter"
|
||||
/>
|
||||
<ElButton
|
||||
class="bill-selector__filter bill-selector__action"
|
||||
@click="handleBillReset"
|
||||
>
|
||||
重置
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
class="bill-selector__filter bill-selector__action"
|
||||
@click="handleBillSearch"
|
||||
>
|
||||
搜索
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="billsLoading" class="bill-selector__empty">加载中...</div>
|
||||
<ElEmpty
|
||||
v-else-if="!candidateBills.length"
|
||||
description="暂无可核销账单"
|
||||
:image-size="60"
|
||||
/>
|
||||
<ElEmpty v-else-if="!displayBills.length" description="暂无可核销账单" :image-size="60" />
|
||||
<div v-else class="bill-selector__list">
|
||||
<div v-for="bill in candidateBills" :key="bill.id" class="bill-row">
|
||||
<div v-for="bill in displayBills" :key="bill.id" class="bill-row">
|
||||
<ElCheckbox
|
||||
:model-value="selectedBillIds.includes(bill.id)"
|
||||
@change="toggleBill(bill)"
|
||||
/>
|
||||
<div class="bill-row__main">
|
||||
<div class="bill-row__title">账单 #{{ bill.id }}</div>
|
||||
<div class="bill-row__title">{{ getBillAssetIdentifier(bill) }}</div>
|
||||
<div class="bill-row__meta">
|
||||
单号:{{ bill.source_no || '-' }} · 未核销
|
||||
{{ formatCollectionCurrency(bill.remaining_amount) }}
|
||||
负责员工:{{ bill.debtor_snapshot?.account_name || '-' }} · 单号:{{
|
||||
bill.source_no || '-'
|
||||
}}
|
||||
· 未核销 {{ formatCollectionCurrency(bill.remaining_amount) }}
|
||||
</div>
|
||||
</div>
|
||||
<ElInputNumber
|
||||
@@ -60,6 +173,19 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!billsLoading && billPagination.total > billPagination.pageSize"
|
||||
class="bill-selector__pagination"
|
||||
>
|
||||
<ElPagination
|
||||
small
|
||||
layout="prev, pager, next"
|
||||
:current-page="billPagination.page"
|
||||
:page-size="billPagination.pageSize"
|
||||
:total="billPagination.total"
|
||||
@current-change="handleBillPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bill-selector__total">
|
||||
已选 {{ selectedBillIds.length }} 张账单,核销合计
|
||||
@@ -97,37 +223,6 @@
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="付款凭证" prop="payment_voucher_keys">
|
||||
<VoucherUpload
|
||||
ref="uploadRef"
|
||||
v-model="form.payment_voucher_keys"
|
||||
voucher-name="付款凭证"
|
||||
:max-count="5"
|
||||
@uploading-change="voucherUploading = $event"
|
||||
@change="handleVoucherChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="外部交易流水号" prop="external_transaction_no">
|
||||
<div class="external-transaction-row">
|
||||
<ElInput
|
||||
v-model="form.external_transaction_no"
|
||||
maxlength="128"
|
||||
show-word-limit
|
||||
placeholder="请输入经人工核对的外部交易流水号,或点击右侧识别凭证预填"
|
||||
:disabled="ocrLoading"
|
||||
/>
|
||||
<ElButton
|
||||
:loading="ocrLoading"
|
||||
:disabled="!voucherKeys.length"
|
||||
@click="handleRecognizeVoucher"
|
||||
>
|
||||
识别凭证
|
||||
</ElButton>
|
||||
</div>
|
||||
<div class="external-transaction-tip"> 识别结果仅供参考,请对照凭证核对后再提交。 </div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="备注" prop="remark">
|
||||
<ElInput
|
||||
v-model="form.remark"
|
||||
@@ -157,27 +252,30 @@
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="submitting || voucherUploading"
|
||||
:disabled="voucherUploading || !enabledPaymentMethodCount"
|
||||
:disabled="voucherUploading || !paymentMethods.length"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ voucherUploading ? '凭证上传中...' : '提交' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { AgentRechargeService, EmployeeCollectionService } from '@/api/modules'
|
||||
import { AgentRechargeService, EmployeeCollectionService, ShopService } from '@/api/modules'
|
||||
import type {
|
||||
EmployeeCollectionAllocation,
|
||||
EmployeeCollectionAllocationRequest,
|
||||
EmployeeCollectionApplication,
|
||||
EmployeeCollectionApplicationRequest,
|
||||
EmployeeCollectionBill,
|
||||
EmployeeCollectionBillQueryParams,
|
||||
EmployeeCollectionCustomerSnapshot,
|
||||
EmployeeCollectionDebtorSnapshot,
|
||||
EmployeeCollectionPaymentMethod
|
||||
} from '@/types/api'
|
||||
import { fenToYuan, yuanToFen } from '@/utils/business/format'
|
||||
@@ -185,9 +283,12 @@
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
import {
|
||||
BILL_SOURCE_OPTIONS,
|
||||
BILL_STATUS_OPTIONS,
|
||||
canCreateApplication,
|
||||
formatCollectionCurrency,
|
||||
normalizeCollectionList
|
||||
normalizeCollectionList,
|
||||
normalizeCollectionPage
|
||||
} from '../../employeeCollectionDisplay'
|
||||
|
||||
interface Props {
|
||||
@@ -198,8 +299,12 @@
|
||||
|
||||
interface BillOption {
|
||||
id: number
|
||||
asset_identifier?: string | null
|
||||
source_no?: string | null
|
||||
remaining_amount: number
|
||||
debtor_account_id?: number | null
|
||||
debtor_snapshot?: EmployeeCollectionDebtorSnapshot | null
|
||||
customer_snapshot?: EmployeeCollectionCustomerSnapshot | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -224,9 +329,31 @@
|
||||
const recognizedVoucherKey = ref('')
|
||||
const paymentMethods = ref<EmployeeCollectionPaymentMethod[]>([])
|
||||
const candidateBills = ref<BillOption[]>([])
|
||||
const selectedBillOptions = ref<Record<number, BillOption>>({})
|
||||
const selectedBillIds = ref<number[]>([])
|
||||
const amountMap = reactive<Record<number, number>>({})
|
||||
|
||||
const shopOptions = ref<Array<{ id: number; shop_name: string }>>([])
|
||||
|
||||
const billSearch = reactive({
|
||||
source_type: undefined as string | undefined,
|
||||
source_no: '',
|
||||
status: undefined as number | undefined,
|
||||
customer_id: undefined as number | undefined,
|
||||
dateRange: [] as string[],
|
||||
created_from: '',
|
||||
created_to: ''
|
||||
})
|
||||
|
||||
const billPagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
let paymentMethodSearchRequestId = 0
|
||||
let shopSearchRequestId = 0
|
||||
|
||||
const form = reactive({
|
||||
payment_method_id: undefined as number | undefined,
|
||||
paid_amount: 0,
|
||||
@@ -238,13 +365,42 @@
|
||||
acting_reason: ''
|
||||
})
|
||||
|
||||
const isActing = computed(() => userStore.isSuperAdmin)
|
||||
const isResubmit = computed(() => !!props.application)
|
||||
const dialogTitle = computed(() => (isResubmit.value ? '修改并重新提交核销申请' : '创建核销申请'))
|
||||
const enabledPaymentMethods = computed(() => paymentMethods.value.filter((item) => item.enabled))
|
||||
const enabledPaymentMethodCount = computed(() => enabledPaymentMethods.value.length)
|
||||
const voucherKeys = computed(() => toVoucherKeyList(form.payment_voucher_keys))
|
||||
|
||||
const currentAccountId = computed(() => userStore.info?.id)
|
||||
|
||||
const findBillOption = (billId: number): BillOption | undefined =>
|
||||
selectedBillOptions.value[billId] || candidateBills.value.find((bill) => bill.id === billId)
|
||||
|
||||
const getBillAssetIdentifier = (bill: BillOption): string => {
|
||||
const source = candidateBills.value.find((item) => item.id === bill.id) || bill
|
||||
return source.customer_snapshot?.asset_identifier || source.asset_identifier || '-'
|
||||
}
|
||||
|
||||
const getBillDebtorAccountId = (billId: number): number | null => {
|
||||
const option = findBillOption(billId)
|
||||
return option?.debtor_account_id ?? option?.debtor_snapshot?.account_id ?? null
|
||||
}
|
||||
|
||||
const isActing = computed(() => {
|
||||
if (!userStore.isSuperAdmin) return false
|
||||
if (!selectedBillIds.value.length) return true
|
||||
return selectedBillIds.value.some((billId) => {
|
||||
const debtorAccountId = getBillDebtorAccountId(billId)
|
||||
return debtorAccountId === null || debtorAccountId !== currentAccountId.value
|
||||
})
|
||||
})
|
||||
|
||||
const displayBills = computed<BillOption[]>(() => {
|
||||
const pageIds = new Set(candidateBills.value.map((bill) => bill.id))
|
||||
const selected = selectedBillIds.value
|
||||
.map((billId) => selectedBillOptions.value[billId])
|
||||
.filter((bill): bill is BillOption => !!bill && !pageIds.has(bill.id))
|
||||
return [...selected, ...candidateBills.value]
|
||||
})
|
||||
|
||||
const paidAmountFen = computed(() => yuanToFen(form.paid_amount) || 0)
|
||||
const totalAmountFen = computed(() =>
|
||||
selectedBillIds.value.reduce((total, billId) => total + (yuanToFen(amountMap[billId]) || 0), 0)
|
||||
@@ -287,7 +443,7 @@
|
||||
]
|
||||
}
|
||||
if (isActing.value) {
|
||||
base.acting_reason = [{ required: false, message: '请填写代办原因', trigger: 'blur' }]
|
||||
base.acting_reason = [{ required: true, message: '请填写代办原因', trigger: 'blur' }]
|
||||
}
|
||||
return base
|
||||
})
|
||||
@@ -298,46 +454,92 @@
|
||||
}
|
||||
})
|
||||
|
||||
const loadPaymentMethods = async () => {
|
||||
const searchPaymentMethods = async (query?: string) => {
|
||||
const requestId = ++paymentMethodSearchRequestId
|
||||
paymentMethodsLoading.value = true
|
||||
try {
|
||||
const res = await EmployeeCollectionService.getPaymentMethods({ page: 1, page_size: 100 })
|
||||
if (res.code === 0) {
|
||||
const res = await EmployeeCollectionService.getPaymentMethods({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
enabled: true,
|
||||
keyword: query?.trim() || undefined
|
||||
})
|
||||
if (res.code === 0 && requestId === paymentMethodSearchRequestId) {
|
||||
paymentMethods.value = normalizeCollectionList<EmployeeCollectionPaymentMethod>(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, '获取收款方式失败'))
|
||||
if (requestId === paymentMethodSearchRequestId) {
|
||||
ElMessage.error(getErrorMessage(error, '获取收款方式失败'))
|
||||
}
|
||||
} finally {
|
||||
paymentMethodsLoading.value = false
|
||||
if (requestId === paymentMethodSearchRequestId) {
|
||||
paymentMethodsLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ensureSelectedPaymentMethod = (id?: number | null, name?: string | null) => {
|
||||
if (!id) return
|
||||
if (!paymentMethods.value.some((item) => item.id === id)) {
|
||||
paymentMethods.value.unshift({
|
||||
id,
|
||||
code: '',
|
||||
name: name || `收款方式 #${id}`,
|
||||
sort: 0,
|
||||
enabled: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaymentMethodVisible = (visible: boolean) => {
|
||||
if (visible && !paymentMethods.value.length) {
|
||||
void searchPaymentMethods('')
|
||||
}
|
||||
}
|
||||
|
||||
const toBillOption = (bill: {
|
||||
id: number
|
||||
asset_identifier?: string | null
|
||||
source_no?: string | null
|
||||
remaining_amount?: number | null
|
||||
debtor_account_id?: number | null
|
||||
debtor_snapshot?: EmployeeCollectionDebtorSnapshot | null
|
||||
customer_snapshot?: EmployeeCollectionCustomerSnapshot | null
|
||||
}): BillOption => ({
|
||||
id: bill.id,
|
||||
asset_identifier: bill.asset_identifier ?? null,
|
||||
source_no: bill.source_no,
|
||||
remaining_amount: bill.remaining_amount ?? 0
|
||||
remaining_amount: bill.remaining_amount ?? 0,
|
||||
debtor_account_id: bill.debtor_account_id ?? null,
|
||||
debtor_snapshot: bill.debtor_snapshot ?? null,
|
||||
customer_snapshot: bill.customer_snapshot ?? null
|
||||
})
|
||||
|
||||
const buildBillQueryParams = (): EmployeeCollectionBillQueryParams => ({
|
||||
source_type: billSearch.source_type,
|
||||
source_no: billSearch.source_no.trim() || undefined,
|
||||
status: billSearch.status,
|
||||
customer_id: billSearch.customer_id,
|
||||
created_from: billSearch.created_from || undefined,
|
||||
created_to: billSearch.created_to || undefined
|
||||
})
|
||||
|
||||
const loadCandidateBills = async () => {
|
||||
billsLoading.value = true
|
||||
try {
|
||||
const res = await EmployeeCollectionService.getBills({ page: 1, page_size: 100 })
|
||||
const list: BillOption[] =
|
||||
res.code === 0
|
||||
? normalizeCollectionList<EmployeeCollectionBill>(res.data)
|
||||
.filter((bill) => canCreateApplication(bill))
|
||||
.map((bill) => toBillOption(bill))
|
||||
: []
|
||||
|
||||
if (props.presetBill && !list.some((bill) => bill.id === props.presetBill?.id)) {
|
||||
list.unshift(toBillOption(props.presetBill))
|
||||
const res = await EmployeeCollectionService.getBills({
|
||||
page: billPagination.page,
|
||||
page_size: billPagination.pageSize,
|
||||
...buildBillQueryParams()
|
||||
})
|
||||
if (res.code === 0) {
|
||||
const { list, total } = normalizeCollectionPage<EmployeeCollectionBill>(res.data)
|
||||
candidateBills.value = list.filter(canCreateApplication).map((bill) => toBillOption(bill))
|
||||
billPagination.total = total
|
||||
} else {
|
||||
candidateBills.value = []
|
||||
billPagination.total = 0
|
||||
}
|
||||
|
||||
candidateBills.value = list
|
||||
} catch (error) {
|
||||
ElMessage.error(getErrorMessage(error, '获取可核销账单失败'))
|
||||
} finally {
|
||||
@@ -345,6 +547,57 @@
|
||||
}
|
||||
}
|
||||
|
||||
const searchBillShops = async (query: string) => {
|
||||
const requestId = ++shopSearchRequestId
|
||||
try {
|
||||
const params: { page: number; page_size: number; shop_name?: string } = {
|
||||
page: 1,
|
||||
page_size: 20
|
||||
}
|
||||
const keyword = query.trim()
|
||||
if (keyword) params.shop_name = keyword
|
||||
const res = await ShopService.getShops(params)
|
||||
if (res.code === 0 && requestId === shopSearchRequestId) {
|
||||
shopOptions.value = res.data.items || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search bill shops failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const syncBillCreatedRange = () => {
|
||||
if (Array.isArray(billSearch.dateRange) && billSearch.dateRange.length === 2) {
|
||||
billSearch.created_from = billSearch.dateRange[0]
|
||||
billSearch.created_to = billSearch.dateRange[1]
|
||||
} else {
|
||||
billSearch.created_from = ''
|
||||
billSearch.created_to = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleBillSearch = () => {
|
||||
syncBillCreatedRange()
|
||||
billPagination.page = 1
|
||||
void loadCandidateBills()
|
||||
}
|
||||
|
||||
const handleBillReset = () => {
|
||||
billSearch.source_type = undefined
|
||||
billSearch.source_no = ''
|
||||
billSearch.status = undefined
|
||||
billSearch.customer_id = undefined
|
||||
billSearch.dateRange = []
|
||||
billSearch.created_from = ''
|
||||
billSearch.created_to = ''
|
||||
billPagination.page = 1
|
||||
void loadCandidateBills()
|
||||
}
|
||||
|
||||
const handleBillPageChange = (page: number) => {
|
||||
billPagination.page = page
|
||||
void loadCandidateBills()
|
||||
}
|
||||
|
||||
const normalizePaidAtForPicker = (value?: string | null): string => {
|
||||
if (!value) return ''
|
||||
const match = value
|
||||
@@ -373,8 +626,10 @@
|
||||
if (index >= 0) {
|
||||
selectedBillIds.value.splice(index, 1)
|
||||
delete amountMap[bill.id]
|
||||
delete selectedBillOptions.value[bill.id]
|
||||
} else {
|
||||
selectedBillIds.value.push(bill.id)
|
||||
selectedBillOptions.value[bill.id] = bill
|
||||
amountMap[bill.id] = fenToYuan(bill.remaining_amount)
|
||||
}
|
||||
}
|
||||
@@ -389,17 +644,30 @@
|
||||
form.remark = ''
|
||||
form.acting_reason = ''
|
||||
selectedBillIds.value = []
|
||||
selectedBillOptions.value = {}
|
||||
Object.keys(amountMap).forEach((key) => delete amountMap[Number(key)])
|
||||
billSearch.source_type = undefined
|
||||
billSearch.source_no = ''
|
||||
billSearch.status = undefined
|
||||
billSearch.customer_id = undefined
|
||||
billSearch.dateRange = []
|
||||
billSearch.created_from = ''
|
||||
billSearch.created_to = ''
|
||||
billPagination.page = 1
|
||||
billPagination.total = 0
|
||||
candidateBills.value = []
|
||||
shopOptions.value = []
|
||||
recognizedVoucherKey.value = ''
|
||||
uploadRef.value?.clearFiles()
|
||||
}
|
||||
|
||||
const initialize = async (): Promise<void> => {
|
||||
resetState()
|
||||
await Promise.all([loadPaymentMethods(), loadCandidateBills()])
|
||||
await Promise.all([searchPaymentMethods(''), loadCandidateBills()])
|
||||
|
||||
if (props.application) {
|
||||
const application = props.application
|
||||
ensureSelectedPaymentMethod(application.payment_method_id, application.payment_method_name)
|
||||
form.payment_method_id = application.payment_method_id ?? undefined
|
||||
form.paid_amount = fenToYuan(application.paid_amount)
|
||||
form.payer_name = application.payer_name || ''
|
||||
@@ -411,18 +679,19 @@
|
||||
|
||||
const allocations = await fetchApplicationAllocations(application.id)
|
||||
allocations.forEach((allocation) => {
|
||||
if (!candidateBills.value.some((item) => item.id === allocation.bill_id)) {
|
||||
candidateBills.value.push({
|
||||
id: allocation.bill_id,
|
||||
source_no: allocation.bill_source_no,
|
||||
remaining_amount: Math.max(
|
||||
allocation.amount,
|
||||
(allocation.bill_receivable_amount ?? 0) -
|
||||
(allocation.bill_received_amount ?? 0) -
|
||||
(allocation.bill_reserved_amount ?? 0),
|
||||
0
|
||||
)
|
||||
})
|
||||
const option: BillOption = {
|
||||
id: allocation.bill_id,
|
||||
source_no: allocation.bill_source_no,
|
||||
remaining_amount: Math.max(
|
||||
allocation.amount,
|
||||
(allocation.bill_receivable_amount ?? 0) -
|
||||
(allocation.bill_received_amount ?? 0) -
|
||||
(allocation.bill_reserved_amount ?? 0),
|
||||
0
|
||||
)
|
||||
}
|
||||
if (!selectedBillOptions.value[allocation.bill_id]) {
|
||||
selectedBillOptions.value[allocation.bill_id] = option
|
||||
}
|
||||
if (!selectedBillIds.value.includes(allocation.bill_id)) {
|
||||
selectedBillIds.value.push(allocation.bill_id)
|
||||
@@ -434,11 +703,10 @@
|
||||
form.paid_amount = fenToYuan(totalAmountFen.value)
|
||||
}
|
||||
} else if (props.presetBill) {
|
||||
const bill = candidateBills.value.find((item) => item.id === props.presetBill?.id)
|
||||
const option = toBillOption(props.presetBill)
|
||||
selectedBillOptions.value[props.presetBill.id] = option
|
||||
selectedBillIds.value = [props.presetBill.id]
|
||||
amountMap[props.presetBill.id] = fenToYuan(
|
||||
bill?.remaining_amount ?? props.presetBill.remaining_amount ?? 0
|
||||
)
|
||||
amountMap[props.presetBill.id] = fenToYuan(option.remaining_amount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,7 +726,7 @@
|
||||
const payload: EmployeeCollectionAllocationRequest[] = []
|
||||
for (const billId of selectedBillIds.value) {
|
||||
const amount = yuanToFen(amountMap[billId]) || 0
|
||||
const option = candidateBills.value.find((bill) => bill.id === billId)
|
||||
const option = findBillOption(billId)
|
||||
if (amount <= 0) {
|
||||
ElMessage.warning('请填写每张账单的核销金额')
|
||||
return null
|
||||
@@ -587,6 +855,36 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__filter {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__action {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -237,7 +237,7 @@
|
||||
]
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '账单编号', prop: 'id' },
|
||||
{ label: '资产标识', prop: 'asset_identifier' },
|
||||
{ label: '来源', prop: 'source_type' },
|
||||
{ label: '关联单号', prop: 'source_no' },
|
||||
{ label: '负责员工', prop: 'debtor_snapshot' },
|
||||
@@ -281,9 +281,10 @@
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'id',
|
||||
label: '账单编号',
|
||||
minWidth: 120,
|
||||
prop: 'asset_identifier',
|
||||
label: '资产标识',
|
||||
minWidth: 200,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: EmployeeCollectionBill) =>
|
||||
h(
|
||||
'span',
|
||||
@@ -291,7 +292,7 @@
|
||||
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
|
||||
onClick: () => handleViewDetail(row)
|
||||
},
|
||||
`#${row.id}`
|
||||
row.customer_snapshot?.asset_identifier || '-'
|
||||
)
|
||||
},
|
||||
{
|
||||
|
||||
277
src/views/operations-reports/activation/index.vue
Normal file
277
src/views/operations-reports/activation/index.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="operations-report-page" id="table-full-screen">
|
||||
<!-- 搜索栏 -->
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
show-expand
|
||||
label-width="100px"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
></ArtSearchBar>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<!-- 表格头部 -->
|
||||
<ArtTableHeader
|
||||
:columnList="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #left>
|
||||
<span v-if="snapshotText" class="report-snapshot">{{ snapshotText }}</span>
|
||||
<ElTag v-if="exportTask" :type="exportStatusType" class="report-export-tag">
|
||||
{{ exportTask.status_name || '处理中' }}
|
||||
</ElTag>
|
||||
<ElButton v-if="canDownload" type="success" link @click="downloadExport">
|
||||
下载
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="exporting" @click="handleExport">导出</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<!-- 表格 -->
|
||||
<ArtTable :loading="loading" :data="tableData" :pagination="false" :marginTop="10">
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ExportTaskService, OperationsReportsService } from '@/api/modules'
|
||||
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { ExportTaskStatus, type ExportTaskDetail } from '@/types/api'
|
||||
import type { ActivationSummaryData, ActivationSummaryItem } from '@/types/api'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { ACTIVATION_GROUP_OPTIONS, ACTIVATION_METRIC_COLUMNS, formatMetric } from '../constants'
|
||||
|
||||
defineOptions({ name: 'OperationsActivationReport' })
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
|
||||
const formatDateTimeValue = (date: Date) => {
|
||||
const offset = -date.getTimezoneOffset()
|
||||
const sign = offset >= 0 ? '+' : '-'
|
||||
const abs = Math.abs(offset)
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
|
||||
date.getHours()
|
||||
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${pad(
|
||||
Math.floor(abs / 60)
|
||||
)}:${pad(abs % 60)}`
|
||||
}
|
||||
|
||||
const getDefaultRange = () => {
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - 29 * 24 * 60 * 60 * 1000)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return [formatDateTimeValue(start), formatDateTimeValue(end)]
|
||||
}
|
||||
|
||||
const searchForm = reactive({
|
||||
dateRange: getDefaultRange() as string[],
|
||||
group_by: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const summary = ref<ActivationSummaryData | null>(null)
|
||||
|
||||
const searchFormItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '统计时间',
|
||||
prop: 'dateRange',
|
||||
type: 'datetimerange',
|
||||
config: {
|
||||
type: 'datetimerange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间',
|
||||
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '分组维度',
|
||||
prop: 'group_by',
|
||||
type: 'select',
|
||||
config: { clearable: true, placeholder: '请选择分组维度' },
|
||||
options: () => ACTIVATION_GROUP_OPTIONS
|
||||
}
|
||||
]
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '分组', prop: 'group_name' },
|
||||
...ACTIVATION_METRIC_COLUMNS.map((col) => ({ label: col.label, prop: col.key }))
|
||||
]
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'group_name',
|
||||
label: '分组',
|
||||
minWidth: 160,
|
||||
fixed: 'left',
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: ActivationSummaryItem) => row.group_name || row.group_key || '-'
|
||||
},
|
||||
...ACTIVATION_METRIC_COLUMNS.map((col) => ({
|
||||
prop: col.key,
|
||||
label: col.label,
|
||||
minWidth: col.minWidth,
|
||||
align: 'right' as const,
|
||||
formatter: (row: ActivationSummaryItem) => formatMetric(row[col.key], col)
|
||||
}))
|
||||
])
|
||||
|
||||
const snapshotText = computed(() => {
|
||||
const dates = summary.value?.snapshot_dates
|
||||
return dates?.length ? `数据快照:${dates.join('、')}` : ''
|
||||
})
|
||||
|
||||
const tableData = computed(() => {
|
||||
const data = summary.value
|
||||
if (!data) return []
|
||||
const rows = [...(data.items || [])]
|
||||
if (data.totals) {
|
||||
rows.push({ ...data.totals, group_name: '合计' })
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
const buildQuery = () => ({
|
||||
start_time: searchForm.dateRange?.[0] || undefined,
|
||||
end_time: searchForm.dateRange?.[1] || undefined,
|
||||
group_by: searchForm.group_by || undefined
|
||||
})
|
||||
|
||||
const getTableData = async () => {
|
||||
if (!searchForm.dateRange?.[0] || !searchForm.dateRange?.[1]) {
|
||||
ElMessage.warning('请选择统计时间')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const summaryRes = await OperationsReportsService.getActivationSummary(buildQuery())
|
||||
|
||||
if (summaryRes.code === 0 && summaryRes.data) {
|
||||
summary.value = summaryRes.data
|
||||
} else {
|
||||
summary.value = null
|
||||
ElMessage.error(summaryRes.msg || '获取设备激活汇总失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取设备激活报表失败:', error)
|
||||
summary.value = null
|
||||
ElMessage.error('获取设备激活报表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.dateRange = getDefaultRange()
|
||||
searchForm.group_by = ''
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
getTableData()
|
||||
}
|
||||
|
||||
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<ExportTaskDetail>({
|
||||
storageKey: 'operations-report-active:activation',
|
||||
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 () => {
|
||||
if (!searchForm.dateRange?.[0] || !searchForm.dateRange?.[1]) {
|
||||
ElMessage.warning('请选择统计时间')
|
||||
return
|
||||
}
|
||||
|
||||
exporting.value = true
|
||||
try {
|
||||
const res = await OperationsReportsService.exportActivationSummary({
|
||||
...buildQuery(),
|
||||
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')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.operations-report-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.report-snapshot {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.report-export-tag {
|
||||
margin-left: 12px;
|
||||
}
|
||||
</style>
|
||||
115
src/views/operations-reports/components/OperationsTrendChart.vue
Normal file
115
src/views/operations-reports/components/OperationsTrendChart.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div ref="chartRef" class="operations-trend-chart" :style="{ height: props.height }"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import type { OperationsTrendSeries } from '../constants'
|
||||
|
||||
defineOptions({ name: 'OperationsTrendChart' })
|
||||
|
||||
interface Props {
|
||||
categories: string[]
|
||||
series: OperationsTrendSeries[]
|
||||
height?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
categories: () => [],
|
||||
series: () => [],
|
||||
height: '20rem'
|
||||
})
|
||||
|
||||
const {
|
||||
chartRef,
|
||||
initChart,
|
||||
getChartInstance,
|
||||
isDark,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle
|
||||
} = useChart()
|
||||
|
||||
const palette = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444']
|
||||
|
||||
const buildOptions = (): EChartsOption => {
|
||||
const hasSecondary = props.series.some((item) => item.yAxisIndex === 1)
|
||||
const labelStyle = getAxisLabelStyle()
|
||||
|
||||
return {
|
||||
color: palette,
|
||||
grid: {
|
||||
top: 44,
|
||||
right: hasSecondary ? 58 : 20,
|
||||
bottom: 10,
|
||||
left: 10,
|
||||
containLabel: true
|
||||
},
|
||||
legend: {
|
||||
top: 8,
|
||||
itemWidth: 12,
|
||||
itemHeight: 8,
|
||||
textStyle: { color: labelStyle.color }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: true,
|
||||
data: props.categories,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(),
|
||||
axisLabel: getAxisLabelStyle()
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
axisLabel: getAxisLabelStyle(),
|
||||
axisLine: getAxisLineStyle(false),
|
||||
splitLine: getSplitLineStyle()
|
||||
},
|
||||
...(hasSecondary
|
||||
? [
|
||||
{
|
||||
type: 'value' as const,
|
||||
max: 100,
|
||||
axisLabel: { ...getAxisLabelStyle(), formatter: '{value}%' },
|
||||
axisLine: getAxisLineStyle(false),
|
||||
splitLine: getSplitLineStyle(false)
|
||||
}
|
||||
]
|
||||
: [])
|
||||
],
|
||||
series: props.series.map((item) => ({
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
yAxisIndex: item.yAxisIndex ?? 0,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
barMaxWidth: 26,
|
||||
connectNulls: false,
|
||||
data: item.data,
|
||||
areaStyle: item.areaStyle ? { opacity: 0.12 } : undefined
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
getChartInstance()?.clear()
|
||||
initChart(buildOptions())
|
||||
}
|
||||
|
||||
watch(isDark, render)
|
||||
watch([() => props.categories, () => props.series], render, { deep: true })
|
||||
|
||||
onMounted(render)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.operations-trend-chart {
|
||||
width: calc(100% + 10px);
|
||||
}
|
||||
</style>
|
||||
96
src/views/operations-reports/constants.ts
Normal file
96
src/views/operations-reports/constants.ts
Normal file
@@ -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
|
||||
}
|
||||
277
src/views/operations-reports/renewal/index.vue
Normal file
277
src/views/operations-reports/renewal/index.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="operations-report-page" id="table-full-screen">
|
||||
<!-- 搜索栏 -->
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
show-expand
|
||||
label-width="100px"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
></ArtSearchBar>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<!-- 表格头部 -->
|
||||
<ArtTableHeader
|
||||
:columnList="columnOptions"
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #left>
|
||||
<span v-if="snapshotText" class="report-snapshot">{{ snapshotText }}</span>
|
||||
<ElTag v-if="exportTask" :type="exportStatusType" class="report-export-tag">
|
||||
{{ exportTask.status_name || '处理中' }}
|
||||
</ElTag>
|
||||
<ElButton v-if="canDownload" type="success" link @click="downloadExport">
|
||||
下载
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="exporting" @click="handleExport">导出</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<!-- 表格 -->
|
||||
<ArtTable :loading="loading" :data="tableData" :pagination="false" :marginTop="10">
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ExportTaskService, OperationsReportsService } from '@/api/modules'
|
||||
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { ExportTaskStatus, type ExportTaskDetail } from '@/types/api'
|
||||
import type { RenewalSummaryData, RenewalSummaryItem } from '@/types/api'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { RENEWAL_GROUP_OPTIONS, RENEWAL_METRIC_COLUMNS, formatMetric } from '../constants'
|
||||
|
||||
defineOptions({ name: 'OperationsRenewalReport' })
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
|
||||
const formatDateTimeValue = (date: Date) => {
|
||||
const offset = -date.getTimezoneOffset()
|
||||
const sign = offset >= 0 ? '+' : '-'
|
||||
const abs = Math.abs(offset)
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
|
||||
date.getHours()
|
||||
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${pad(
|
||||
Math.floor(abs / 60)
|
||||
)}:${pad(abs % 60)}`
|
||||
}
|
||||
|
||||
const getDefaultRange = () => {
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - 29 * 24 * 60 * 60 * 1000)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return [formatDateTimeValue(start), formatDateTimeValue(end)]
|
||||
}
|
||||
|
||||
const searchForm = reactive({
|
||||
dateRange: getDefaultRange() as string[],
|
||||
group_by: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const summary = ref<RenewalSummaryData | null>(null)
|
||||
|
||||
const searchFormItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '统计时间',
|
||||
prop: 'dateRange',
|
||||
type: 'datetimerange',
|
||||
config: {
|
||||
type: 'datetimerange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间',
|
||||
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '分组维度',
|
||||
prop: 'group_by',
|
||||
type: 'select',
|
||||
config: { clearable: true, placeholder: '请选择分组维度' },
|
||||
options: () => RENEWAL_GROUP_OPTIONS
|
||||
}
|
||||
]
|
||||
|
||||
const columnOptions = [
|
||||
{ label: '分组', prop: 'group_name' },
|
||||
...RENEWAL_METRIC_COLUMNS.map((col) => ({ label: col.label, prop: col.key }))
|
||||
]
|
||||
|
||||
const { columnChecks, columns } = useCheckedColumns(() => [
|
||||
{
|
||||
prop: 'group_name',
|
||||
label: '分组',
|
||||
minWidth: 160,
|
||||
fixed: 'left',
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: RenewalSummaryItem) => row.group_name || row.group_key || '-'
|
||||
},
|
||||
...RENEWAL_METRIC_COLUMNS.map((col) => ({
|
||||
prop: col.key,
|
||||
label: col.label,
|
||||
minWidth: col.minWidth,
|
||||
align: 'right' as const,
|
||||
formatter: (row: RenewalSummaryItem) => formatMetric(row[col.key], col)
|
||||
}))
|
||||
])
|
||||
|
||||
const snapshotText = computed(() => {
|
||||
const dates = summary.value?.snapshot_dates
|
||||
return dates?.length ? `数据快照:${dates.join('、')}` : ''
|
||||
})
|
||||
|
||||
const tableData = computed(() => {
|
||||
const data = summary.value
|
||||
if (!data) return []
|
||||
const rows = [...(data.items || [])]
|
||||
if (data.totals) {
|
||||
rows.push({ ...data.totals, group_name: '合计' })
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
const buildQuery = () => ({
|
||||
start_time: searchForm.dateRange?.[0] || undefined,
|
||||
end_time: searchForm.dateRange?.[1] || undefined,
|
||||
group_by: searchForm.group_by || undefined
|
||||
})
|
||||
|
||||
const getTableData = async () => {
|
||||
if (!searchForm.dateRange?.[0] || !searchForm.dateRange?.[1]) {
|
||||
ElMessage.warning('请选择统计时间')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const summaryRes = await OperationsReportsService.getRenewalSummary(buildQuery())
|
||||
|
||||
if (summaryRes.code === 0 && summaryRes.data) {
|
||||
summary.value = summaryRes.data
|
||||
} else {
|
||||
summary.value = null
|
||||
ElMessage.error(summaryRes.msg || '获取套餐续费汇总失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取套餐续费报表失败:', error)
|
||||
summary.value = null
|
||||
ElMessage.error('获取套餐续费报表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.dateRange = getDefaultRange()
|
||||
searchForm.group_by = ''
|
||||
getTableData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
getTableData()
|
||||
}
|
||||
|
||||
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<ExportTaskDetail>({
|
||||
storageKey: 'operations-report-active:renewal',
|
||||
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 () => {
|
||||
if (!searchForm.dateRange?.[0] || !searchForm.dateRange?.[1]) {
|
||||
ElMessage.warning('请选择统计时间')
|
||||
return
|
||||
}
|
||||
|
||||
exporting.value = true
|
||||
try {
|
||||
const res = await OperationsReportsService.exportRenewalSummary({
|
||||
...buildQuery(),
|
||||
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')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.operations-report-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.report-snapshot {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.report-export-tag {
|
||||
margin-left: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #actions>
|
||||
<template #left>
|
||||
<ElButton
|
||||
v-if="canCreate"
|
||||
type="primary"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #actions>
|
||||
<template #left>
|
||||
<ElButton v-if="canExport" :icon="Download" @click="openExportDialog"> 导出 </ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
v-model:columns="columnChecks"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #actions>
|
||||
<template #left>
|
||||
<ElButton v-if="canCreate" type="primary" :icon="Plus" @click="openCreateDialog">
|
||||
新增配置
|
||||
</ElButton>
|
||||
|
||||
@@ -118,14 +118,14 @@
|
||||
</div>
|
||||
<div class="credential-list">
|
||||
<div
|
||||
v-for="(entry, index) in form.credentials"
|
||||
v-for="entry in form.credentials"
|
||||
:key="entry.localId"
|
||||
class="credential-row"
|
||||
>
|
||||
<ElInput
|
||||
v-model="entry.key"
|
||||
placeholder="凭证字段名"
|
||||
maxlength="100"
|
||||
readonly
|
||||
aria-label="凭证字段名"
|
||||
/>
|
||||
<ElInput
|
||||
@@ -136,16 +136,8 @@
|
||||
autocomplete="new-password"
|
||||
aria-label="凭证值"
|
||||
/>
|
||||
<ElButton
|
||||
type="danger"
|
||||
link
|
||||
:icon="Delete"
|
||||
aria-label="删除凭证字段"
|
||||
@click="removeCredential(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ElButton type="primary" plain :icon="Plus" @click="addCredential">添加凭证字段</ElButton>
|
||||
<template v-if="form.provider_type === 'wechat_v2'">
|
||||
<ElDivider content-position="left">退款专用可选凭证(原路退款)</ElDivider>
|
||||
<div class="refund-credential-fields">
|
||||
|
||||
@@ -424,7 +424,6 @@
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { getCompatibleNumericId } from '@/utils/business/id'
|
||||
import {
|
||||
BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS,
|
||||
CommonStatus,
|
||||
getBusinessUserGroupBusinessLineLabel,
|
||||
getStatusText,
|
||||
@@ -568,7 +567,6 @@
|
||||
status: undefined as number | undefined,
|
||||
business_owner_account_id: undefined as number | undefined,
|
||||
business_user_group_id: undefined as number | undefined,
|
||||
business_line: undefined as string | undefined,
|
||||
ungrouped: undefined as number | undefined
|
||||
}
|
||||
|
||||
@@ -722,19 +720,6 @@
|
||||
value: group.id
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: '业务线',
|
||||
prop: 'business_line',
|
||||
type: 'select' as const,
|
||||
config: {
|
||||
clearable: true,
|
||||
placeholder: '请选择业务线'
|
||||
},
|
||||
options: BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: '未分组',
|
||||
prop: 'ungrouped',
|
||||
@@ -933,8 +918,7 @@
|
||||
const businessLine = getBusinessUserGroupBusinessLineLabel(
|
||||
row.business_user_group_business_line
|
||||
)
|
||||
const text =
|
||||
businessLine && businessLine !== '-' ? `${name}(${businessLine})` : name
|
||||
const text = businessLine && businessLine !== '-' ? `${name}(${businessLine})` : name
|
||||
if (row.business_user_group_enabled) return text
|
||||
return h(ElTag, { type: 'danger', size: 'small' }, { default: () => `${text}(已停用)` })
|
||||
}
|
||||
@@ -1204,9 +1188,7 @@
|
||||
...(!isAgentAccount.value && {
|
||||
business_owner_account_id: searchForm.business_owner_account_id,
|
||||
business_user_group_id: searchForm.business_user_group_id,
|
||||
business_line: searchForm.business_line || undefined,
|
||||
ungrouped:
|
||||
searchForm.ungrouped === undefined ? undefined : searchForm.ungrouped === 1
|
||||
ungrouped: searchForm.ungrouped === undefined ? undefined : searchForm.ungrouped === 1
|
||||
})
|
||||
}
|
||||
const res = await ShopService.getShops(params)
|
||||
|
||||
Reference in New Issue
Block a user