fix: some
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 28m40s

This commit is contained in:
luo
2026-09-18 18:11:53 +08:00
parent 5e7f5eaba4
commit 08889d2e42
55 changed files with 2888 additions and 414 deletions

View File

@@ -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
- 指标字段名未经后端确认 → 通过集中常量降低风险,联调时单点修正。
- 报表页可能返回大量行 → 首版不做虚拟滚动,依赖后端分页/汇总;后续按需优化。

View File

@@ -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`

View File

@@ -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** 表格对应单元格显示 `-`

View File

@@ -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 文件

View File

@@ -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` 通过

View File

@@ -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`

View File

@@ -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 显示「设备激活情况报表」

View File

@@ -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** 前端轮询任务状态,任务完成后可下载文件

View File

@@ -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`

View File

@@ -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`(预期为是)。

View File

@@ -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`(如需补充账单候选查询字段类型)

View File

@@ -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 页

View File

@@ -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 展示

View File

@@ -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` 通过

View File

@@ -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

View File

@@ -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 // 创建时间结束
}
// 创建换货单请求

View File

@@ -57,3 +57,5 @@ export { PackageTrafficAlertService } from './packageTrafficAlert'
export { PollingPriorityQueueService } from './pollingPriorityQueue'
export { AssetWalletService } from './assetWallet'
export { OperationsReportsService } from './operationsReports'

View 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
)
}
}

View File

@@ -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、精确 MIMEimage/jpeg与通配 MIMEimage/*
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;

View File

@@ -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'
}
}
}

View File

@@ -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",

View File

@@ -428,8 +428,11 @@
"exportRefund": "导出退款",
"exportAgentRecharge": "导出代理充值",
"exportExchange": "导出换货",
"exportCommissionRecord": "导出佣金记录",
"exportPackageTrafficAlert": "导出套餐真流量预警",
"exportCommissionRecord": "导出佣金明细",
"exportPackageTrafficAlert": "导出套餐真流量达量预警",
"exportExpiringAsset": "导出临期资产",
"exportOperationsActivation": "导出设备激活情况报表",
"exportOperationsRenewal": "导出套餐续费情况报表",
"exportTaskDetail": "导出任务详情",
"exchangeManagement": "换货管理",
"exchangeDetail": "换货单详情",

View File

@@ -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: '&#xe7ae;'
},
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]
}
}
]
}
// 组件中心

View File

@@ -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' // 套餐续费报表
}
// 主页路由 - 修改为资产信息页面

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 // 创建时间结束
}
// 资产分配记录

View File

@@ -11,6 +11,9 @@ export type ExportTaskScene =
| 'exchange'
| 'commission_record'
| 'package_traffic_alert'
| 'expiring_asset'
| 'operations_activation'
| 'operations_renewal'
export type ExportTaskFormat = 'xlsx' | 'csv'

View File

@@ -153,3 +153,6 @@ export * from './pollingPriorityQueue'
// 资产钱包自动续费相关
export * from './assetWallet'
// 运营报表相关
export * from './operationsReport'

View 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>

View File

@@ -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']

View File

@@ -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()
}

View File

@@ -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,18 +301,16 @@
}
])
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
}
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()
@@ -317,10 +326,16 @@
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
if (expiresFrom) params.start_time = expiresFrom
if (expiresTo) params.end_time = expiresTo
const response = await AssetService.getExpiringAssets(params)
return params
}
const loadAssets = async () => {
loading.value = true
try {
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 {

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportExpiringAssetTaskList' })
</script>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportOperationsActivationTaskList' })
</script>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportOperationsRenewalTaskList' })
</script>

View File

@@ -15,7 +15,7 @@
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #actions>
<template #left>
<ElButton
type="danger"
plain

View File

@@ -15,7 +15,7 @@
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #actions>
<template #left>
<ElButton @click="goAssociationList">返回关联列表</ElButton>
</template>
</ArtTableHeader>

View File

@@ -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()

View File

@@ -2,6 +2,7 @@
<div class="my-commission-page">
<!-- 标签页 -->
<ElCard shadow="never">
<div class="commission-tabs">
<ElTabs v-model="activeTab">
<!-- 佣金明细 -->
<ElTabPane label="佣金明细" name="commission">
@@ -187,6 +188,31 @@
</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"
>
<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;

View File

@@ -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>

View 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 { 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>

View 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>

View File

@@ -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]
}))
// 刷新表格

View File

@@ -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 v-if="billsLoading" class="bill-selector__empty">加载中...</div>
<ElEmpty
v-else-if="!candidateBills.length"
description="暂无可核销账单"
:image-size="60"
<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="!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) {
if (requestId === paymentMethodSearchRequestId) {
ElMessage.error(getErrorMessage(error, '获取收款方式失败'))
}
} finally {
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,8 +679,7 @@
const allocations = await fetchApplicationAllocations(application.id)
allocations.forEach((allocation) => {
if (!candidateBills.value.some((item) => item.id === allocation.bill_id)) {
candidateBills.value.push({
const option: BillOption = {
id: allocation.bill_id,
source_no: allocation.bill_source_no,
remaining_amount: Math.max(
@@ -422,7 +689,9 @@
(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;

View File

@@ -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 || '-'
)
},
{

View 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>

View 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>

View 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
}

View 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>

View File

@@ -16,7 +16,7 @@
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #actions>
<template #left>
<ElButton
v-if="canCreate"
type="primary"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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)