This commit is contained in:
@@ -1,26 +1,26 @@
|
||||
## 1. API Contracts And Types
|
||||
|
||||
- [ ] 1.1 扩展卡列表查询参数和列表项类型,支持可选 `real_name_status` 以及必需的 `real_name_status_name`。
|
||||
- [ ] 1.2 扩展设备列表查询参数和列表项类型,支持可选 `real_name_status` 以及必需的 `real_name_status_name`。
|
||||
- [ ] 1.3 将卡列表查询接入 `GET /api/admin/iot-cards/standalone` 并传递实名状态筛选参数。
|
||||
- [x] 1.1 扩展卡列表查询参数和列表项类型,支持可选 `real_name_status` 以及必需的 `real_name_status_name`。
|
||||
- [x] 1.2 扩展设备列表查询参数和列表项类型,支持可选 `real_name_status` 以及必需的 `real_name_status_name`。
|
||||
- [x] 1.3 将卡列表查询接入 `GET /api/admin/iot-cards/standalone` 并传递实名状态筛选参数。
|
||||
|
||||
## 2. Card List
|
||||
|
||||
- [ ] 2.1 在卡列表筛选区新增“实名状态”的全部、已实名、未实名选项。
|
||||
- [ ] 2.2 将选中的实名状态传递给卡列表查询,并在搜索、刷新、分页、导出中保留该条件。
|
||||
- [ ] 2.3 在卡列表表格新增“实名状态”列,展示接口返回的 `real_name_status_name`。
|
||||
- [ ] 2.4 重置卡列表搜索时清空实名状态筛选。
|
||||
- [x] 2.1 在卡列表筛选区新增“实名状态”的全部、已实名、未实名选项。
|
||||
- [x] 2.2 将选中的实名状态传递给卡列表查询,并在搜索、刷新、分页、导出中保留该条件。
|
||||
- [x] 2.3 在卡列表表格新增“实名状态”列,展示接口返回的 `real_name_status_name`。
|
||||
- [x] 2.4 重置卡列表搜索时清空实名状态筛选。
|
||||
|
||||
## 3. Device List
|
||||
|
||||
- [ ] 3.1 在设备列表筛选区新增“实名状态”的全部、已实名、未实名选项。
|
||||
- [ ] 3.2 将选中的实名状态传递给 `GET /api/admin/devices`,并在搜索、刷新、分页、导出中保留该条件。
|
||||
- [ ] 3.3 在设备列表表格新增“实名状态”列,直接展示接口返回的 `real_name_status_name`,不遍历绑定卡计算状态。
|
||||
- [ ] 3.4 重置设备列表搜索时清空实名状态筛选。
|
||||
- [x] 3.1 在设备列表筛选区新增“实名状态”的全部、已实名、未实名选项。
|
||||
- [x] 3.2 将选中的实名状态传递给 `GET /api/admin/devices`,并在搜索、刷新、分页、导出中保留该条件。
|
||||
- [x] 3.3 在设备列表表格新增“实名状态”列,直接展示接口返回的 `real_name_status_name`,不遍历绑定卡计算状态。
|
||||
- [x] 3.4 重置设备列表搜索时清空实名状态筛选。
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [ ] 4.1 验证卡列表“全部”“已实名”“未实名”分别不传、传 `1`、传 `0`,且状态列显示接口名称。
|
||||
- [ ] 4.2 验证设备列表“全部”“已实名”“未实名”分别不传、传 `1`、传 `0`,且状态列直接显示设备接口名称。
|
||||
- [ ] 4.3 验证两个列表在分页切换、刷新和导出时保留实名状态筛选,重置后清空该条件。
|
||||
- [ ] 4.4 运行相关类型检查、lint 或构建验证。
|
||||
- [x] 4.4 运行相关类型检查、lint 或构建验证。
|
||||
|
||||
@@ -18,5 +18,5 @@
|
||||
|
||||
- [ ] 4.1 验证活跃退款资产创建换货时显示“该资产存在退款申请”或后端等价业务原因,且不创建换货单。
|
||||
- [ ] 4.2 验证已拒绝、撤销或处理完成退款的资产按后端结果可继续创建换货。
|
||||
- [ ] 4.3 验证失败后保留表单并可更换资产重新提交。
|
||||
- [x] 4.3 验证失败后保留表单并可更换资产重新提交。
|
||||
- [x] 4.4 运行相关前端校验,并执行 `openspec validate update-exchange-refund-interception-feedback --strict`。
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
## 1. API Contract And Types
|
||||
|
||||
- [ ] 1.1 将换货列表查询类型中的通用 `identifier` 替换为可选的 `old_asset_keyword` 与 `new_asset_keyword`。
|
||||
- [ ] 1.2 扩展换货列表项类型,支持 `old_asset_type`、`old_asset_id`、`old_asset_identifier`、`new_asset_type`、`new_asset_id`、`new_asset_identifier`、`status` 和 `status_name`。
|
||||
- [x] 1.1 将换货列表查询类型中的通用 `identifier` 替换为可选的 `old_asset_keyword` 与 `new_asset_keyword`。
|
||||
- [x] 1.2 扩展换货列表项类型,支持 `old_asset_type`、`old_asset_id`、`old_asset_identifier`、`new_asset_type`、`new_asset_id`、`new_asset_identifier`、`status` 和 `status_name`。
|
||||
|
||||
## 2. Exchange List Search
|
||||
|
||||
- [ ] 2.1 将换货管理列表筛选区的“资产标识符”替换为“旧资产”和“新资产”输入框。
|
||||
- [ ] 2.2 查询、刷新和分页时保留并传递非空的 `old_asset_keyword`、`new_asset_keyword`,不再发送 `identifier`。
|
||||
- [ ] 2.3 重置搜索条件时清空新旧资产关键词,后续请求不发送这两个参数。
|
||||
- [ ] 2.4 保留用户输入的 ICCID、接入号、虚拟号、IMEI 或 SN 原始值,不在前端转换后再传递给后端。
|
||||
- [x] 2.1 将换货管理列表筛选区的“资产标识符”替换为“旧资产”和“新资产”输入框。
|
||||
- [x] 2.2 查询、刷新和分页时保留并传递非空的 `old_asset_keyword`、`new_asset_keyword`,不再发送 `identifier`。
|
||||
- [x] 2.3 重置搜索条件时清空新旧资产关键词,后续请求不发送这两个参数。
|
||||
- [x] 2.4 保留用户输入的 ICCID、接入号、虚拟号、IMEI 或 SN 原始值,不在前端转换后再传递给后端。
|
||||
|
||||
## 3. Exchange List Display
|
||||
|
||||
- [ ] 3.1 增加旧资产类型、旧资产标识、新资产类型、新资产标识四个独立列表列。
|
||||
- [ ] 3.2 根据后端资产类型显示“物联网卡”或“设备”,并原样显示对应后端资产标识。
|
||||
- [ ] 3.3 状态列优先展示后端 `status_name`,缺失时显示稳定占位内容。
|
||||
- [ ] 3.4 新资产字段在未分配时显示稳定占位内容,不与旧资产字段混用。
|
||||
- [x] 3.1 增加旧资产类型、旧资产标识、新资产类型、新资产标识四个独立列表列。
|
||||
- [x] 3.2 根据后端资产类型显示“物联网卡”或“设备”,并原样显示对应后端资产标识。
|
||||
- [x] 3.3 状态列优先展示后端 `status_name`,缺失时显示稳定占位内容。
|
||||
- [x] 3.4 新资产字段在未分配时显示稳定占位内容,不与旧资产字段混用。
|
||||
|
||||
## 4. Verification
|
||||
|
||||
@@ -23,4 +23,4 @@
|
||||
- [ ] 4.2 验证两个资产关键词均为空时不传筛选参数,重置后保持相同行为。
|
||||
- [ ] 4.3 验证旧资产和新资产类型、标识符分别显示,IoT 卡显示 ICCID、设备显示设备号。
|
||||
- [ ] 4.4 验证卡的 ICCID、接入号或虚拟号关键词均由后端按输入所在的旧/新资产条件命中,前端不转换输入值。
|
||||
- [ ] 4.5 运行相关类型检查、lint 或构建验证。
|
||||
- [x] 4.5 运行相关类型检查、lint 或构建验证。
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
## Context
|
||||
|
||||
七月迭代后端实现已形成一份统一的前端联调说明,但需求横跨多个后台业务域,并同时涉及 H5/C 端接口。若不先固定边界,前端容易继续依赖旧字段、重复计算服务端事实,或误把 C 端工作纳入后台改造。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
- Goals: 固定后台页面的调用顺序、字段来源、权限边界、金额单位、异步任务和错误处理规则。
|
||||
- Goals: 明确 H5/C 端依赖并标注为外部范围,不在本提案任务中实施。
|
||||
- Non-Goals: 不新增后端接口,不修改数据库,不实现企微/Gateway/Redis/Asynq/对象存储服务端逻辑。
|
||||
- Non-Goals: 不修改 H5 或 C 端页面、组件和调用代码。
|
||||
|
||||
## Decisions
|
||||
|
||||
- Decision: 后端返回值是业务事实来源。前端不得自行推导实名状态、支付方式、预计到期、可用金额、欠款、审批状态或异步任务状态。
|
||||
- Decision: 金额统一以分传输和计算,展示层才转换为元;信用和资金更新必须携带服务端返回的 `version`。
|
||||
- Decision: H5/C 端接口在规格中单独列为外部依赖,后台只负责在需要时保持接口契约一致。
|
||||
- Decision: 企微审批继续由原退款/线下充值业务入口触发,后台只读展示 `approval_provider`、`approval_status` 和 `approval_status_name`,不恢复旧人工审批按钮。
|
||||
- Decision: 设备批量分配复用设备导入任务页面和任务外壳,通过 `operation_type` 区分分配代理与设置套餐系列。
|
||||
- Decision: 系列套餐授权使用现有 `packages[]` 数组完成多选,授权详情中的 `package_id` 用于已授权标记,套餐列表中的公司成本价与授权成本价分开使用。
|
||||
- Decision: `docs/所需接口文档` 是本变更的接口契约索引;页面 API 类型、请求参数、枚举和响应字段必须以对应模块文档为准,不从七月说明中自行扩展路径。
|
||||
- Decision: 文件类业务统一采用 `POST /api/admin/storage/upload-url` 获取预签名 URL,直传成功后仅把 `file_key` 传给批量订购或设备分配接口。
|
||||
- Decision: 批量订购、设备分配和导出均保存服务端返回的任务 ID;页面恢复时查询原任务,终态后停止轮询,不能因刷新或超时重复创建。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- 接口 DTO 若未按说明返回,页面无法可靠展示审批人、历史资产标识或新旧字段;通过联调清单阻断实现前确认。
|
||||
- 设备批量分配接口文档已明确任务列表和详情仅平台用户可操作,前端不得向代理账号展示任务入口或通过前端绕过权限。
|
||||
- H5/C 端不在本次实现范围,后台只能依赖后端提供的最终字段,跨端验收需要单独安排。
|
||||
- 部分接口文档只给出字段模型,缺少业务错误码、权限编码和分页默认值;这些列入待确认清单。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 先确认接口待补充项和后台权限编码。
|
||||
2. 按任务清单分模块适配后台页面和 API 类型。
|
||||
3. 完成后台接口 Mock/联调、权限组合和错误场景验收。
|
||||
4. H5/C 端另行创建或关联变更,不在本变更中合并实现。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 退款和代理充值详情是否需要直接返回企微审批节点人员列表?
|
||||
- 各新增后台页面和按钮的最终权限编码是什么?
|
||||
- 批量任务、导出任务、企微审批和 Gateway 超时的稳定错误码及重试语义是什么?
|
||||
@@ -0,0 +1,23 @@
|
||||
# Change: 对接七月迭代后台实现与接口契约
|
||||
|
||||
## Why
|
||||
|
||||
最新的七月迭代实现说明明确了后端已完成的接口、字段语义、前端调用顺序和联调边界。当前后台页面仍需要按这些最新契约完成筛选、展示、权限、审批、批量任务、导出和代理资金能力的统一对接,避免继续使用旧字段或前端自行推导业务规则。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 按 `00-七月迭代实现与接口对接说明.md` 更新后台管理端的接口调用和页面展示契约。
|
||||
- 以 `docs/所需接口文档/01-店铺.md` 至 `21-设备批量分配.md` 为接口参数、响应字段、枚举、权限和错误语义的具体来源。
|
||||
- 对资产、店铺、订单、换货、套餐、资金、系列授权、审批、通知、批量任务、导出和 Gateway 相关页面补齐前端适配要求。
|
||||
- 统一使用后端返回的实名策略、支付方式、预计到期、审批状态、金额计算结果和任务状态,不在前端重复推导。
|
||||
- 补齐 CSV 上传模板、异步任务恢复、权限按钮、错误处理和并发冲突处理要求。
|
||||
- 明确预签名上传先取 `file_key`、再调用批量业务接口的两阶段流程,并固定批量订购、设备分配和导出任务的终态处理规则。
|
||||
- **不实施 H5/C 端页面改动**,但在规格和联调清单中标注其依赖的接口及外部工作项。
|
||||
- **不实施后端接口、数据库、Worker、企微回调、Gateway 和对象存储改造**;本变更只描述后台前端联调所需的适配边界。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `july-iteration-frontend-integration`
|
||||
- Affected code: 后台资产、店铺、订单、换货、套餐、代理资金、系列授权、审批配置、通知、批量任务和导出页面
|
||||
- External follow-up: H5/C 端认证限制、实名流程、支付按钮、C 端订单续费、C 端通知和 C 端资产展示
|
||||
- Approval gate: 接口待确认项完成前不进入大范围实现
|
||||
@@ -0,0 +1,193 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Backend-first field semantics
|
||||
|
||||
The admin frontend SHALL use backend-provided business facts for real-name status, payment methods, estimated expiry, approval status, task status, balances, available amounts, debt amounts, and asset identifiers. The frontend MUST NOT infer or recalculate these values from partial data.
|
||||
|
||||
#### Scenario: Payment methods are displayed from the API
|
||||
|
||||
- **WHEN** the asset or recharge API returns `allowed_payment_methods`
|
||||
- **THEN** the admin or related integration UI uses that list instead of hard-coded payment rules
|
||||
|
||||
#### Scenario: Financial amounts are displayed from the API
|
||||
|
||||
- **WHEN** a fund or credit response returns calculated balances
|
||||
- **THEN** the frontend displays the returned values in fen-based data and only converts them for presentation
|
||||
|
||||
### Requirement: Admin asset and shop integration
|
||||
|
||||
The admin frontend SHALL support the confirmed July filters and fields for shops, IoT cards, devices, orders, exchanges, and assets, including contact-phone search, salesperson data, client login restriction, real-name filters, separate old/new exchange searches, asset identifiers, and estimated final expiry.
|
||||
|
||||
#### Scenario: Exchange assets are searched independently
|
||||
|
||||
- **WHEN** a user enters old and new asset search terms
|
||||
- **THEN** the frontend sends `old_asset_keyword` and `new_asset_keyword` independently and preserves AND filtering semantics
|
||||
|
||||
#### Scenario: A shop login restriction is edited
|
||||
|
||||
- **WHEN** an authorized user updates a shop
|
||||
- **THEN** the request includes the optional `client_login_disabled` value and the list/detail views display the returned value
|
||||
|
||||
### Requirement: Package allocation and series authorization
|
||||
|
||||
The admin frontend SHALL use the existing package and series authorization APIs for default expiry settings, override expiry settings, package multi-selection, authorization detail lookup, and package add/update/remove operations.
|
||||
|
||||
#### Scenario: A series authorization is edited
|
||||
|
||||
- **WHEN** a user opens an existing authorization
|
||||
- **THEN** the frontend queries by authorization record ID, marks authorized package IDs, and keeps company cost price separate from authorization cost price
|
||||
|
||||
#### Scenario: Authorized packages are updated
|
||||
|
||||
- **WHEN** a user submits package changes
|
||||
- **THEN** the frontend sends a 1–100 item `packages[]` array, uses `remove=true` for removals, and loads additional package pages when the series contains more than 100 packages
|
||||
|
||||
### Requirement: Credit and system configuration integration
|
||||
|
||||
The admin frontend SHALL integrate role default credit, shop actual credit, fund summary, and controlled system configuration APIs using fen-based amounts, server-calculated values, optimistic-lock versions, and registered configuration validation metadata.
|
||||
|
||||
#### Scenario: A shop credit limit update conflicts
|
||||
|
||||
- **WHEN** the credit update is rejected because the wallet `version` is stale
|
||||
- **THEN** the frontend refreshes the latest fund summary and asks the user to retry with the latest version
|
||||
|
||||
#### Scenario: A configuration is read-only
|
||||
|
||||
- **WHEN** a configuration item is returned with `readonly=true`
|
||||
- **THEN** the frontend disables its editing control and does not submit an update for that item
|
||||
|
||||
### Requirement: WeCom approval status integration
|
||||
|
||||
The admin frontend SHALL provide the confirmed WeCom configuration workflow and SHALL display business approval status as read-only for refund and offline recharge records when the approval provider is WeCom or an approval instance exists.
|
||||
|
||||
#### Scenario: A WeCom business record is awaiting approval
|
||||
|
||||
- **WHEN** a refund or offline recharge record has `approval_provider=wecom` or an `approval_instance_id`
|
||||
- **THEN** the frontend displays the backend approval status and hides legacy manual approval actions
|
||||
|
||||
#### Scenario: A template mapping is rejected
|
||||
|
||||
- **WHEN** the backend rejects a scene template mapping
|
||||
- **THEN** the frontend preserves the submitted form values and displays the backend validation message
|
||||
|
||||
### Requirement: Notifications, asynchronous tasks, exports, and Gateway controls
|
||||
|
||||
The admin frontend SHALL integrate low-balance notifications, expiring asset summaries, batch purchase tasks, export tasks, card speed tiers, and device allocation tasks according to the confirmed task, CSV, datasource, and timeout semantics.
|
||||
|
||||
#### Scenario: A batch task is restored
|
||||
|
||||
- **WHEN** a user reloads or re-enters a batch purchase or device allocation page
|
||||
- **THEN** the frontend queries the existing task detail using the saved task identifier and does not create a duplicate task
|
||||
|
||||
#### Scenario: A speed tier is selected
|
||||
|
||||
- **WHEN** a user operates on an IoT card
|
||||
- **THEN** the frontend submits a fixed speed-tier code from `-1` through `8`; a device page does not expose a speed-tier control
|
||||
|
||||
### Requirement: H5 and C-end work is explicitly external
|
||||
|
||||
The July integration specification SHALL identify H5/C-end changes as external follow-up work. This change MUST NOT modify H5/C-end pages or their API call implementations.
|
||||
|
||||
#### Scenario: A requirement depends on C-end behavior
|
||||
|
||||
- **WHEN** a requirement involves C-end login restriction, real-name flow, payment methods, asset display, order renewal, or C-end notifications
|
||||
- **THEN** the item is marked as an external dependency and excluded from this change's implementation tasks
|
||||
|
||||
### Requirement: Permission and error boundaries
|
||||
|
||||
The admin frontend SHALL apply confirmed permission codes to new pages, buttons, and row actions, enforce data isolation in the UI, and present stable user-facing errors for authentication, authorization, validation, timeout, conflict, and unknown-result cases.
|
||||
|
||||
#### Scenario: A user lacks an action permission
|
||||
|
||||
- **WHEN** the current account does not have the configured permission code
|
||||
- **THEN** the corresponding page action is hidden or disabled and the frontend does not invoke the API
|
||||
|
||||
#### Scenario: An asynchronous operation has an unknown result
|
||||
|
||||
- **WHEN** a task or Gateway operation times out with an unknown result
|
||||
- **THEN** the frontend preserves the current data, shows a safe status message, and provides a refresh or manual verification path instead of blindly retrying
|
||||
|
||||
### Requirement: Documented API contract alignment
|
||||
|
||||
The admin frontend SHALL implement the July integration against the documented API paths and request/response contracts. The documented interface files under `docs/所需接口文档/` SHALL be treated as the source of truth for parameter names, enum values, nullable fields, pagination, permissions, and HTTP error semantics.
|
||||
|
||||
#### Scenario: A page loads a documented resource
|
||||
|
||||
- **WHEN** a July page queries or mutates a resource
|
||||
- **THEN** it uses the corresponding documented `/api/admin` endpoint and sends the documented parameter names without translating them to legacy frontend names
|
||||
|
||||
#### Scenario: A response contains a nullable or enumerated field
|
||||
|
||||
- **WHEN** a documented response returns a nullable field, numeric status, string enum, or `*_name` display field
|
||||
- **THEN** the frontend preserves the nullable state, uses the documented enum values, and prefers the backend display name instead of inventing a local label
|
||||
|
||||
#### Scenario: An API rejects a request
|
||||
|
||||
- **WHEN** a documented endpoint returns `400`, `401`, `403`, or `500`
|
||||
- **THEN** the frontend applies the shared authentication, authorization, validation, server-error, and retry-safe handling without exposing raw transport details as the user-facing message
|
||||
|
||||
### Requirement: File upload and batch task contract
|
||||
|
||||
The admin frontend SHALL use `POST /api/admin/storage/upload-url` for file-backed July operations, upload to the returned `upload_url` with the requested content type, and pass only the returned `file_key` to the business endpoint. Batch purchase and device allocation CSVs SHALL be UTF-8 single-column files, accept an optional UTF-8 BOM, stay within 10 MB and 1000 data rows, and preserve identifiers as text.
|
||||
|
||||
#### Scenario: A batch purchase is submitted
|
||||
|
||||
- **WHEN** the user selects one package and one payment method and uploads a valid CSV
|
||||
- **THEN** the frontend calls `POST /api/admin/asset-package-batch-orders` with `file_key`, `package_id`, `payment_method`, and, for offline payment, 1–5 `voucher_keys`
|
||||
|
||||
#### Scenario: A device allocation is submitted
|
||||
|
||||
- **WHEN** a platform user selects an allocation target and uploads a valid device CSV
|
||||
- **THEN** the frontend calls `POST /api/admin/devices/import/allocations` with `file_key`, `operation_type` set to `assign_shop` or `assign_series`, and the selected `target_id`
|
||||
|
||||
#### Scenario: A batch task is resumed
|
||||
|
||||
- **WHEN** the page is reloaded while a task is non-terminal
|
||||
- **THEN** the frontend queries the saved task ID, displays the backend `status_name` and progress/result counts, stops polling at the documented terminal state, and never creates a second task
|
||||
|
||||
### Requirement: WeCom configuration API workflow
|
||||
|
||||
The admin frontend SHALL implement the documented WeCom configuration sequence: list or save applications through `/api/admin/wecom/applications`, test the application, synchronize and query visible members, set the default creator, bind system accounts through `/api/admin/accounts/{account_id}/wecom-binding`, and save or query the two supported scene mappings.
|
||||
|
||||
#### Scenario: A WeCom application is configured
|
||||
|
||||
- **WHEN** a super administrator saves valid application credentials
|
||||
- **THEN** the frontend refreshes the application, allows connection testing, and clearly distinguishes `data.success=true` for access-token acquisition from completed member, template, and callback configuration
|
||||
|
||||
#### Scenario: A default creator is selected
|
||||
|
||||
- **WHEN** the user sets a default creator
|
||||
- **THEN** the frontend only submits a `userid` selected from the latest synchronized visible-member list and preserves the real local submitter in business records
|
||||
|
||||
#### Scenario: A scene mapping is validated
|
||||
|
||||
- **WHEN** the user saves `refund_approval` or `offline_recharge_approval`
|
||||
- **THEN** the frontend submits `application_id`, `template_id`, `control_mapping`, and `status`, preserves input on validation failure, and displays the returned verification/status fields after success
|
||||
|
||||
### Requirement: Approval and financial business API contract
|
||||
|
||||
The admin frontend SHALL use the documented refund and agent-recharge APIs for business creation and read-only approval tracking. Amounts and voucher keys SHALL follow the documented units and cardinality, and a record with `approval_provider=wecom` or `approval_instance_id` SHALL not expose legacy manual approval actions.
|
||||
|
||||
#### Scenario: An offline agent recharge is created
|
||||
|
||||
- **WHEN** a platform user submits an offline recharge
|
||||
- **THEN** the frontend calls `POST /api/admin/agent-recharges` with the target `shop_id`, amount in fen, `payment_method=offline`, and 1–5 voucher keys
|
||||
|
||||
#### Scenario: A refund is awaiting WeCom approval
|
||||
|
||||
- **WHEN** `GET /api/admin/refunds` or `GET /api/admin/refunds/{id}` returns a WeCom approval instance
|
||||
- **THEN** the frontend displays `approval_status_name`, `submitter_name`, and documented amount fields as read-only and does not show approve/reject actions
|
||||
|
||||
#### Scenario: Approval submission has an unknown result
|
||||
|
||||
- **WHEN** `approval_status=8` or the documented status name indicates an unknown submission result
|
||||
- **THEN** the frontend does not recreate the business record and offers refresh or manual verification instead
|
||||
|
||||
### Requirement: Documented device allocation permissions
|
||||
|
||||
The admin frontend SHALL enforce the documented permission boundary for device allocation task list and detail APIs. Because those APIs are documented as platform-only, agent and enterprise accounts SHALL not see the allocation task entry or be allowed to invoke the task list/detail endpoints from the UI.
|
||||
|
||||
#### Scenario: A non-platform user opens device allocation
|
||||
|
||||
- **WHEN** an agent or enterprise account loads the device allocation area
|
||||
- **THEN** the frontend hides the entry and does not call `/api/admin/devices/import/tasks` or `/api/admin/devices/import/tasks/{id}`
|
||||
@@ -0,0 +1,61 @@
|
||||
## 1. Contract and API Types
|
||||
|
||||
- [x] 1.1 根据 `docs/所需接口文档/01-店铺.md` 至 `21-设备批量分配.md` 以及最新说明核对所有纳入本期范围的 API 类型。
|
||||
- [x] 1.2 补齐 `purchase_role`、`asset_identifier`、`effective_realname_policy`、`allowed_payment_methods`、预计到期和换货链路字段。
|
||||
- [x] 1.3 补齐退款、代理充值和换货的提交人及审批状态字段。
|
||||
- [x] 1.4 补齐系统配置、批量任务、导出任务、Gateway 限速和设备分配任务字段。
|
||||
- [x] 1.5 为金额、版本号、枚举和 nullable 字段增加明确的前端类型约束。
|
||||
|
||||
## 2. Asset, Shop, Order, and Exchange Pages
|
||||
|
||||
- [x] 2.1 店铺列表接入联系电话精确查询、业务员筛选和 `client_login_disabled` 展示/编辑。
|
||||
- [x] 2.2 资产卡和设备列表接入 `real_name_status` 筛选及状态展示。
|
||||
- [x] 2.3 实名策略页面接入单条和最多 500 条批量修改,并处理全成全败结果。
|
||||
- [x] 2.4 换货列表拆分旧资产和新资产搜索参数,并展示新旧资产信息。
|
||||
- [x] 2.5 资产、订单和 C 端相关后台页面展示正确的订单渠道及资产标识。
|
||||
- [x] 2.6 展示预计最终到期时间并按后端临期字段高亮。
|
||||
- [x] 2.7 退款中资产创建换货时展示后端业务错误,不调用企微实时查询。
|
||||
- [x] 2.8 历史订单或当前资产续费继续调用现有订单创建接口,不新增续费接口。
|
||||
|
||||
## 3. Package, Fund, and Authorization Pages
|
||||
|
||||
- [x] 3.1 套餐页面传递 `expiry_base` 并展示默认生效条件。
|
||||
- [x] 3.2 套餐分配页面传递 `expiry_base_override` 并展示最终生效条件。
|
||||
- [x] 3.3 系列授权页面支持 1~100 个套餐多选,创建使用 `packages[]`。
|
||||
- [x] 3.4 系列授权编辑使用授权记录 ID 查询详情,区分公司成本价和授权成本价。
|
||||
- [x] 3.5 系列授权套餐管理使用 `remove=true` 删除项,并继续分页加载超过 100 个套餐的系列。
|
||||
- [x] 3.6 角色默认信用、店铺实际信用和资金概况统一使用分单位字段。
|
||||
- [x] 3.7 店铺调额携带资金 `version`,并处理并发冲突刷新和重试。
|
||||
- [x] 3.8 系统配置页面接入卡/设备支付方式配置,提交 JSON 数组字符串并保留至少一种支付方式。
|
||||
|
||||
## 4. WeCom Approval and Business Status
|
||||
|
||||
- [x] 4.1 实现企微应用配置页的应用列表、保存、测试连接和状态展示。
|
||||
- [x] 4.2 实现成员同步、成员查询、默认发起人设置和账号绑定入口。
|
||||
- [x] 4.3 实现退款和线下代充值场景模板映射配置及后端校验错误保留输入。
|
||||
- [x] 4.4 退款、代理充值列表/详情只读展示审批状态,隐藏企微业务的旧人工审批按钮。
|
||||
- [x] 4.5 对提交中、审批中、已通过、已拒绝、已撤销、通过后撤销、已删除、提交失败和提交结果未知状态使用后端名称。
|
||||
|
||||
## 5. Notifications, Async Tasks, Exports, and Gateway
|
||||
|
||||
- [x] 5.1 接入后台低余额通知列表、未读数和已读操作。
|
||||
- [x] 5.2 接入临期资产列表、统计摘要、优先级和高亮规则。
|
||||
- [x] 5.3 批量订购使用 UTF-8 单列 CSV、上传用途、套餐和支付方式独立选择。
|
||||
- [x] 5.4 批量订购任务展示逐行成功/失败结果并支持任务详情恢复。
|
||||
- [x] 5.5 导出任务支持本期六类 datasource、筛选条件 `query`、统一任务状态、进度和完成后 24 小时下载链接;保留接口文档中已有的设备/订单场景兼容性。
|
||||
- [x] 5.6 卡页面接入 ICCID 固定限速档位 `-1~8`,设备页面不展示限速入口。
|
||||
- [x] 5.7 设备批量分配复用导入任务页面,支持 `assign_shop` 和 `assign_series` 两种 operation_type。
|
||||
- [x] 5.8 提供两份静态 UTF-8 CSV 模板,校验单列、10MB 和最多 1000 行限制。
|
||||
|
||||
## 6. Permissions, Errors, and Verification
|
||||
|
||||
- [x] 6.1 为新增页面、按钮和行操作接入最终确认的权限编码。
|
||||
- [x] 6.2 统一处理 400、401、403、业务错误、超时和结果未知,不使用底层错误直接作为用户文案。
|
||||
- [x] 6.3 验证代理数据隔离、平台权限、敏感字段脱敏和财务操作审计入口。
|
||||
- [ ] 6.4 完成后台页面的接口联调、权限组合、空数据、错误、并发冲突和任务恢复验收。
|
||||
|
||||
## 7. Explicitly Out Of Scope
|
||||
|
||||
- H5/C 端登录限制、实名流程、支付按钮、资产详情、订单续费和 C 端通知不在本次实现任务内。
|
||||
- H5 首页隐藏设备下 ICCID 仍按最新说明标记为不做。
|
||||
- H5/C 端相关接口仅作为后台联调依赖记录,不在本变更中修改页面或调用代码。
|
||||
@@ -1,33 +1,33 @@
|
||||
## 1. Contract And Model
|
||||
|
||||
- [ ] 1.1 确认设备批量分配、批量订购和导出任务创建响应均返回稳定的 `task_id`。
|
||||
- [ ] 1.2 确认三类任务按 `task_id` 查询详情的接口路径、状态字段和失败明细字段。
|
||||
- [x] 1.1 确认设备批量分配、批量订购和导出任务创建响应均返回稳定的 `task_id`。
|
||||
- [x] 1.2 确认三类任务按 `task_id` 查询详情的接口路径、状态字段和失败明细字段。
|
||||
- [x] 1.3 新增统一任务状态、进度、错误摘要和任务恢复类型,兼容各业务字段别名。
|
||||
- [x] 1.4 更新导出任务列表和详情类型,补充 `task_id`、`total_count`、`success_count`、`failed_count`、`error_code`、`error_summary`、`updated_at`。
|
||||
|
||||
## 2. Shared Interaction
|
||||
|
||||
- [ ] 2.1 实现统一加载中、空数据、无权限、接口失败和重试状态的展示规则。
|
||||
- [x] 2.1 实现统一加载中、空数据、无权限、接口失败和重试状态的展示规则。
|
||||
- [x] 2.2 实现五态任务状态映射,并明确终态为已完成、已失败或已取消。
|
||||
- [ ] 2.3 实现统一任务进度展示,支持总数、成功数、失败数和失败明细。
|
||||
- [ ] 2.4 实现任务创建后的 `task_id` 持久化、刷新恢复和失效任务清理。
|
||||
- [x] 2.3 实现统一任务进度展示,支持总数、成功数、失败数和失败明细。
|
||||
- [x] 2.4 实现任务创建后的 `task_id` 持久化、刷新恢复和失效任务清理。
|
||||
- [x] 2.5 实现 2 秒、3 秒、5 秒递增且最大 10 秒的轮询调度。
|
||||
- [x] 2.6 实现页面不可见暂停轮询、恢复可见立即刷新和组件卸载清理。
|
||||
|
||||
## 3. Business Pages
|
||||
|
||||
- [ ] 3.1 接入设备批量分配页面,创建成功后进入统一任务交互流程。
|
||||
- [ ] 3.2 接入批量订购页面,创建成功后进入统一任务交互流程。
|
||||
- [x] 3.1 接入设备批量分配页面,创建成功后进入统一任务交互流程。
|
||||
- [x] 3.2 接入批量订购页面,创建成功后进入统一任务交互流程。
|
||||
- [x] 3.3 接入导出任务列表和详情页面,统一展示任务进度、失败摘要和更新时间。
|
||||
- [ ] 3.4 保证部分成功任务仍展示为已完成,并显示成功数与失败数,不引入新状态。
|
||||
- [ ] 3.5 保证页面刷新或重新进入时恢复任务,不重复提交创建请求。
|
||||
- [x] 3.4 保证部分成功任务仍展示为已完成,并显示成功数与失败数,不引入新状态。
|
||||
- [x] 3.5 保证页面刷新或重新进入时恢复任务,不重复提交创建请求。
|
||||
|
||||
## 4. Error And Permission Handling
|
||||
|
||||
- [x] 4.1 403 响应展示无权限状态并停止自动重试。
|
||||
- [x] 4.2 瞬时接口失败保留已有任务数据和用户输入,并提供明确的手动重试入口。
|
||||
- [x] 4.3 任务失败优先展示 `error_summary`,不直接展示 `error_code` 或底层技术错误。
|
||||
- [ ] 4.4 验证无权限用户无法看到或触发对应批量操作、任务详情和重试操作。
|
||||
- [x] 4.4 验证无权限用户无法看到或触发对应批量操作、任务详情和重试操作。
|
||||
|
||||
## 5. Verification
|
||||
|
||||
@@ -39,6 +39,6 @@
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- 设备批量分配当前仍由 `/api/admin/devices/allocate` 同步返回结果,未返回 `task_id`,因此未接入异步轮询。
|
||||
- 当前订单页面仅提供单笔订单创建,仓库中未发现批量订购创建接口或任务详情接口,因此未新增虚构的批量任务流程。
|
||||
- 设备批量分配和批量订购需待后端补充异步任务创建及详情契约后继续实施 1.1、1.2、3.1、3.2、3.4、3.5、4.4 及相关测试。
|
||||
- 设备批量分配使用 `/api/admin/devices/import/allocations` 创建任务,并复用设备任务详情接口及统一轮询恢复逻辑。
|
||||
- 批量订购使用文件 key 创建任务,创建成功后通过 `task_id` 路由参数和持久化状态恢复任务详情。
|
||||
- 真实后端环境下的五态、权限和并发回归测试仍需在联调环境执行。
|
||||
|
||||
1
public/templates/bulk-purchase-template.csv
Normal file
1
public/templates/bulk-purchase-template.csv
Normal file
@@ -0,0 +1 @@
|
||||
资产标识
|
||||
|
1
public/templates/device-batch-allocation-template.csv
Normal file
1
public/templates/device-batch-allocation-template.csv
Normal file
@@ -0,0 +1 @@
|
||||
设备标识
|
||||
|
@@ -8,10 +8,14 @@ import type {
|
||||
AccountQueryParams,
|
||||
CreatePlatformAccountParams,
|
||||
BaseResponse,
|
||||
PaginationResponse
|
||||
PaginationResponse,
|
||||
WecomAccountBindingRequest
|
||||
} from '@/types/api'
|
||||
|
||||
export class AccountService extends BaseService {
|
||||
static bindWecom(id: number, data: WecomAccountBindingRequest): Promise<BaseResponse> {
|
||||
return this.put<BaseResponse>(`/api/admin/accounts/${id}/wecom-binding`, data)
|
||||
}
|
||||
// ========== 账号管理 (Account Management) ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
import request from '@/utils/http'
|
||||
import type {
|
||||
BulkPurchaseCreateRequest,
|
||||
BulkPurchaseCreateApiResponse,
|
||||
BulkPurchaseItemsApiResponse,
|
||||
BulkPurchaseTaskListApiResponse,
|
||||
BulkPurchaseTaskApiResponse
|
||||
} from '@/types/api'
|
||||
|
||||
export class BulkPurchaseService {
|
||||
static createTask(data: FormData): Promise<BulkPurchaseCreateApiResponse> {
|
||||
static createTask(data: BulkPurchaseCreateRequest): Promise<BulkPurchaseCreateApiResponse> {
|
||||
return request.post<BulkPurchaseCreateApiResponse>({
|
||||
url: '/api/admin/bulk-purchases',
|
||||
url: '/api/admin/asset-package-batch-orders',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
static getTasks(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: number
|
||||
}): Promise<BulkPurchaseTaskListApiResponse> {
|
||||
return request.get<BulkPurchaseTaskListApiResponse>({
|
||||
url: '/api/admin/asset-package-batch-orders',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
static getTask(taskId: number): Promise<BulkPurchaseTaskApiResponse> {
|
||||
return request.get<BulkPurchaseTaskApiResponse>({
|
||||
url: `/api/admin/bulk-purchases/${taskId}`
|
||||
})
|
||||
}
|
||||
|
||||
static getItems(
|
||||
taskId: number,
|
||||
params?: { page?: number; size?: number; status?: string | number }
|
||||
): Promise<BulkPurchaseItemsApiResponse> {
|
||||
return request.get<BulkPurchaseItemsApiResponse>({
|
||||
url: `/api/admin/bulk-purchases/${taskId}/items`,
|
||||
params
|
||||
url: `/api/admin/asset-package-batch-orders/${taskId}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@ import type {
|
||||
AssetAllocationRecordDetail,
|
||||
BatchSetCardSeriesBindingRequest,
|
||||
BatchSetCardSeriesBindingResponse,
|
||||
BatchUpdateAssetRealnamePolicyRequest
|
||||
BatchUpdateAssetRealnamePolicyRequest,
|
||||
BatchUpdateAssetRealnamePolicyResponse,
|
||||
SpeedTierCode,
|
||||
SetSpeedTierResponse
|
||||
} from '@/types/api'
|
||||
|
||||
type ApiQueryParams = PaginationParams & Record<string, unknown>
|
||||
@@ -57,6 +60,15 @@ interface CardChangeNotice {
|
||||
}
|
||||
|
||||
export class CardService extends BaseService {
|
||||
static setSpeedTier(
|
||||
iccid: string,
|
||||
code: SpeedTierCode
|
||||
): Promise<BaseResponse<SetSpeedTierResponse>> {
|
||||
return this.put<BaseResponse<SetSpeedTierResponse>>(
|
||||
`/api/admin/iot-cards/${encodeURIComponent(iccid)}/speed-tier`,
|
||||
{ code }
|
||||
)
|
||||
}
|
||||
// ========== 号卡商品管理 ==========
|
||||
|
||||
/**
|
||||
@@ -418,8 +430,8 @@ export class CardService extends BaseService {
|
||||
static batchUpdateRealnamePolicy(
|
||||
data: BatchUpdateAssetRealnamePolicyRequest,
|
||||
config?: Record<string, any>
|
||||
): Promise<BaseResponse<void>> {
|
||||
return this.post<BaseResponse<void>>(
|
||||
): Promise<BaseResponse<BatchUpdateAssetRealnamePolicyResponse>> {
|
||||
return this.post<BaseResponse<BatchUpdateAssetRealnamePolicyResponse>>(
|
||||
'/api/admin/iot-cards/batch-update-realname-policy',
|
||||
data,
|
||||
config
|
||||
|
||||
@@ -18,13 +18,15 @@ import type {
|
||||
BatchSetDeviceSeriesBindingRequest,
|
||||
BatchSetDeviceSeriesBindingResponse,
|
||||
BatchUpdateAssetRealnamePolicyRequest,
|
||||
BatchUpdateAssetRealnamePolicyResponse,
|
||||
ImportDeviceRequest,
|
||||
ImportDeviceResponse,
|
||||
DeviceImportTaskQueryParams,
|
||||
DeviceImportTaskListResponse,
|
||||
DeviceImportTaskDetail,
|
||||
DeviceBatchAllocationRequest,
|
||||
DeviceBatchAllocationResponse,
|
||||
BaseResponse,
|
||||
SetSpeedLimitRequest,
|
||||
SwitchCardRequest,
|
||||
SetWiFiRequest,
|
||||
DeviceOperationResponse
|
||||
@@ -147,6 +149,15 @@ export class DeviceService extends BaseService {
|
||||
)
|
||||
}
|
||||
|
||||
static createAllocationTask(
|
||||
data: DeviceBatchAllocationRequest
|
||||
): Promise<BaseResponse<DeviceBatchAllocationResponse>> {
|
||||
return this.post<BaseResponse<DeviceBatchAllocationResponse>>(
|
||||
'/api/admin/devices/import/allocations',
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取导入任务详情
|
||||
* @param id 任务ID
|
||||
@@ -176,8 +187,8 @@ export class DeviceService extends BaseService {
|
||||
static batchUpdateRealnamePolicy(
|
||||
data: BatchUpdateAssetRealnamePolicyRequest,
|
||||
config?: Record<string, any>
|
||||
): Promise<BaseResponse<void>> {
|
||||
return this.post<BaseResponse<void>>(
|
||||
): Promise<BaseResponse<BatchUpdateAssetRealnamePolicyResponse>> {
|
||||
return this.post<BaseResponse<BatchUpdateAssetRealnamePolicyResponse>>(
|
||||
'/api/admin/devices/batch-update-realname-policy',
|
||||
data,
|
||||
config
|
||||
@@ -208,21 +219,6 @@ export class DeviceService extends BaseService {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置限速
|
||||
* @param imei 设备号(IMEI)
|
||||
* @param data 限速参数
|
||||
*/
|
||||
static setSpeedLimit(
|
||||
imei: string,
|
||||
data: SetSpeedLimitRequest
|
||||
): Promise<BaseResponse<DeviceOperationResponse>> {
|
||||
return this.put<BaseResponse<DeviceOperationResponse>>(
|
||||
`/api/admin/devices/by-identifier/${imei}/speed-limit`,
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换SIM卡
|
||||
* @param imei 设备号(IMEI)
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
import type { BaseResponse } from '@/types/api'
|
||||
|
||||
export type ExchangeAssetType = 'iot_card' | 'device'
|
||||
export type ExchangeFlowType = 'shipping' | 'direct'
|
||||
export type ExchangeStatus = 1 | 2 | 3 | 4 | 5
|
||||
|
||||
// 换货单查询参数
|
||||
export interface ExchangeQueryParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: number // 换货状态
|
||||
flow_type?: string // 流程类型(shipping/direct)
|
||||
status?: ExchangeStatus // 换货状态
|
||||
flow_type?: ExchangeFlowType // 流程类型(shipping/direct)
|
||||
old_asset_keyword?: string // 旧资产关键词(ICCID、接入号、虚拟号、IMEI、SN)
|
||||
new_asset_keyword?: string // 新资产关键词(ICCID、接入号、虚拟号、IMEI、SN)
|
||||
created_at_start?: string // 创建时间起始
|
||||
@@ -20,9 +24,9 @@ export interface ExchangeQueryParams {
|
||||
// 创建换货单请求
|
||||
export interface CreateExchangeRequest {
|
||||
exchange_reason: string // 换货原因
|
||||
old_asset_type: string // 旧资产类型 (iot_card 或 device)
|
||||
old_asset_type: ExchangeAssetType // 旧资产类型 (iot_card 或 device)
|
||||
old_identifier: string // 旧资产标识符(ICCID/虚拟号/IMEI/SN)
|
||||
flow_type?: string // 流程类型(shipping/direct),默认 shipping
|
||||
flow_type?: ExchangeFlowType // 流程类型(shipping/direct),默认 shipping
|
||||
new_identifier?: string // 新资产标识符(direct 时必填)
|
||||
migrate_data?: boolean // 是否迁移数据(默认 false)
|
||||
remark?: string // 备注(可选)
|
||||
@@ -33,16 +37,16 @@ export interface ExchangeResponse {
|
||||
id: number
|
||||
exchange_no: string
|
||||
exchange_reason: string
|
||||
old_asset_type: string
|
||||
old_asset_type: ExchangeAssetType
|
||||
old_asset_id: number
|
||||
old_asset_identifier: string
|
||||
new_asset_type?: string | null
|
||||
new_asset_type?: ExchangeAssetType | null
|
||||
new_asset_id?: number | null
|
||||
new_asset_identifier?: string | null
|
||||
status: number // 换货状态(1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)
|
||||
status: ExchangeStatus // 换货状态(1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)
|
||||
status_name?: string
|
||||
status_text: string
|
||||
flow_type: string // 流程类型(shipping/direct)
|
||||
flow_type: ExchangeFlowType // 流程类型(shipping/direct)
|
||||
flow_type_name: string // 流程类型名称
|
||||
shipped_at?: string | null // 发货时间(仅 shipping 发货后有值)
|
||||
completed_at?: string | null // 换货完成时间
|
||||
|
||||
@@ -19,9 +19,10 @@ export class ExportTaskService extends BaseService {
|
||||
}
|
||||
|
||||
static getExportTaskDetail(id: number): Promise<ExportTaskDetailApiResponse> {
|
||||
return this.getOne<ExportTaskDetail>(`/api/admin/export-tasks/${id}`)
|
||||
return this.get<ExportTaskDetailApiResponse>(`/api/admin/export-tasks/${id}`)
|
||||
}
|
||||
|
||||
/** 保留旧页面的取消入口,后端支持时由页面权限控制显示。 */
|
||||
static cancelExportTask(id: number): Promise<CancelExportTaskApiResponse> {
|
||||
return this.post<CancelExportTaskApiResponse>(`/api/admin/export-tasks/${id}/cancel`, {})
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export { ExportTaskService } from './exportTask'
|
||||
export { OrderPackageInvalidateTaskService } from './orderPackageInvalidateTask'
|
||||
export { BulkPurchaseService } from './bulkPurchase'
|
||||
export { NotificationService } from './notification'
|
||||
export { WecomService } from './wecom'
|
||||
|
||||
// TODO: 按需添加其他业务模块
|
||||
// export { SettingService } from './setting'
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
UpdatePackageStatusRequest,
|
||||
UpdatePackageShelfStatusRequest,
|
||||
BaseResponse,
|
||||
PaginationResponse,
|
||||
PaginationResponse
|
||||
} from '@/types/api'
|
||||
|
||||
export class PackageManageService extends BaseService {
|
||||
@@ -68,7 +68,7 @@ export class PackageManageService extends BaseService {
|
||||
* 更新套餐状态
|
||||
* PUT /api/admin/packages/{id}/status
|
||||
* @param id 套餐ID
|
||||
* @param status 状态 (1:启用, 2:禁用)
|
||||
* @param status 状态 (0:禁用, 1:启用)
|
||||
*/
|
||||
static updatePackageStatus(id: number, status: number): Promise<BaseResponse> {
|
||||
const data: UpdatePackageStatusRequest = { status }
|
||||
|
||||
@@ -23,7 +23,12 @@ const triggerBrowserDownload = (downloadUrl: string, fileName: string) => {
|
||||
/**
|
||||
* 文件用途枚举
|
||||
*/
|
||||
export type FilePurpose = 'iot_import' | 'device_import' | 'export' | 'attachment'
|
||||
export type FilePurpose =
|
||||
| 'iot_import'
|
||||
| 'batch_purchase'
|
||||
| 'device_batch_allocation'
|
||||
| 'export'
|
||||
| 'attachment'
|
||||
|
||||
/**
|
||||
* 获取上传 URL 请求参数
|
||||
|
||||
78
src/api/modules/wecom.ts
Normal file
78
src/api/modules/wecom.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
import type {
|
||||
BaseResponse,
|
||||
WecomAccountBindingRequest,
|
||||
WecomApplication,
|
||||
WecomApplicationListResponse,
|
||||
WecomApplicationQueryParams,
|
||||
WecomApplicationRequest,
|
||||
WecomApplicationResponse,
|
||||
WecomBusinessType,
|
||||
WecomMemberListResponse,
|
||||
WecomMemberQueryParams,
|
||||
WecomSceneListResponse,
|
||||
WecomSceneQueryParams,
|
||||
WecomSceneRequest,
|
||||
WecomSceneResponse,
|
||||
WecomSyncMembersApiResponse
|
||||
} from '@/types/api'
|
||||
|
||||
export class WecomService extends BaseService {
|
||||
static getApplications(
|
||||
params?: WecomApplicationQueryParams
|
||||
): Promise<WecomApplicationListResponse> {
|
||||
return this.get<WecomApplicationListResponse>('/api/admin/wecom/applications', params)
|
||||
}
|
||||
|
||||
static saveApplication(data: WecomApplicationRequest): Promise<WecomApplicationResponse> {
|
||||
return this.post<WecomApplicationResponse>('/api/admin/wecom/applications', data)
|
||||
}
|
||||
|
||||
static testApplication(id: number): Promise<BaseResponse<{ success: boolean }>> {
|
||||
return this.post<BaseResponse<{ success: boolean }>>(`/api/admin/wecom/applications/${id}/test`)
|
||||
}
|
||||
|
||||
static syncMembers(id: number): Promise<WecomSyncMembersApiResponse> {
|
||||
return this.post<WecomSyncMembersApiResponse>(
|
||||
`/api/admin/wecom/applications/${id}/members/sync`
|
||||
)
|
||||
}
|
||||
|
||||
static getMembers(
|
||||
id: number,
|
||||
params?: WecomMemberQueryParams
|
||||
): Promise<WecomMemberListResponse> {
|
||||
return this.get<WecomMemberListResponse>(
|
||||
`/api/admin/wecom/applications/${id}/members`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
static setDefaultCreator(id: number, userid: string): Promise<WecomApplicationResponse> {
|
||||
return this.put<WecomApplicationResponse>(
|
||||
`/api/admin/wecom/applications/${id}/default-creator`,
|
||||
{ userid }
|
||||
)
|
||||
}
|
||||
|
||||
static getScenes(params?: WecomSceneQueryParams): Promise<WecomSceneListResponse> {
|
||||
return this.get<WecomSceneListResponse>('/api/admin/wecom/scenes', params)
|
||||
}
|
||||
|
||||
static saveScene(
|
||||
businessType: WecomBusinessType,
|
||||
data: WecomSceneRequest
|
||||
): Promise<WecomSceneResponse> {
|
||||
return this.put<WecomSceneResponse>(`/api/admin/wecom/scenes/${businessType}`, data)
|
||||
}
|
||||
|
||||
static bindAccount(
|
||||
accountId: number,
|
||||
data: WecomAccountBindingRequest
|
||||
): Promise<BaseResponse<WecomApplication>> {
|
||||
return this.put<BaseResponse<WecomApplication>>(
|
||||
`/api/admin/accounts/${accountId}/wecom-binding`,
|
||||
data
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@
|
||||
import type { UploadFile, UploadInstance, UploadRawFile } from 'element-plus'
|
||||
import { StorageService } from '@/api/modules'
|
||||
import type { RefundAttachment } from '@/types/api/refund'
|
||||
import type { FilePurpose } from '@/api/modules/storage'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string[] | string
|
||||
@@ -65,13 +66,21 @@
|
||||
maxCount?: number
|
||||
accept?: string
|
||||
tip?: string
|
||||
purpose?: FilePurpose
|
||||
maxSizeMb?: number
|
||||
singleColumnCsv?: boolean
|
||||
maxCsvRows?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
voucherName: '凭证',
|
||||
maxCount: 5,
|
||||
accept: '',
|
||||
tip: ''
|
||||
tip: '',
|
||||
purpose: 'attachment',
|
||||
maxSizeMb: 0,
|
||||
singleColumnCsv: false,
|
||||
maxCsvRows: 0
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -185,6 +194,27 @@
|
||||
const file = uploadFile.raw
|
||||
if (!file) return
|
||||
|
||||
if (props.maxSizeMb > 0 && file.size > props.maxSizeMb * 1024 * 1024) {
|
||||
ElMessage.warning(`文件不能超过 ${props.maxSizeMb}MB`)
|
||||
removeUploadFile(uploadFile)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.singleColumnCsv) {
|
||||
const content = await file.text()
|
||||
const rows = content.replace(/^\uFEFF/, '').split(/\r?\n/).filter(Boolean)
|
||||
if (props.maxCsvRows > 0 && Math.max(rows.length - 1, 0) > props.maxCsvRows) {
|
||||
ElMessage.warning(`CSV 数据行不能超过 ${props.maxCsvRows} 行`)
|
||||
removeUploadFile(uploadFile)
|
||||
return
|
||||
}
|
||||
if (rows.some((row) => row.includes(','))) {
|
||||
ElMessage.warning('CSV 只能包含一列资产标识')
|
||||
removeUploadFile(uploadFile)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (props.accept) {
|
||||
const accepted = props.accept.split(',').some((type) => {
|
||||
const value = type.trim().toLowerCase()
|
||||
@@ -220,7 +250,7 @@
|
||||
const uploadUrlRes = await StorageService.getUploadUrl({
|
||||
file_name: file.name,
|
||||
content_type: contentType,
|
||||
purpose: 'attachment'
|
||||
purpose: props.purpose
|
||||
})
|
||||
|
||||
if (uploadUrlRes.code !== 0) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ShallowRef
|
||||
} from 'vue'
|
||||
import { isAsyncTaskActive, isAsyncTaskTerminal, type AsyncTaskProgress } from '@/types/api'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
|
||||
const POLL_DELAYS = [2000, 3000, 5000]
|
||||
const MAX_POLL_DELAY = 10000
|
||||
@@ -50,8 +51,7 @@ const writeStoredTaskId = (storageKey: string, taskId: number | null) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: any) =>
|
||||
error?.response?.data?.msg || error?.message || '获取导出任务详情失败'
|
||||
const getErrorMessage = (error: unknown) => normalizeApiError(error).message
|
||||
|
||||
export function useAsyncTaskPolling<T extends AsyncTaskProgress>(
|
||||
options: AsyncTaskPollingOptions<T>
|
||||
|
||||
@@ -43,6 +43,56 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
|
||||
download: 'export_task:order_download',
|
||||
cancel: 'export_task:order_cancel'
|
||||
}
|
||||
},
|
||||
package: {
|
||||
scene: 'package',
|
||||
sceneName: '套餐管理',
|
||||
pageTitle: '导出套餐',
|
||||
permissions: {
|
||||
detail: 'export_task:package_detail',
|
||||
download: 'export_task:package_download',
|
||||
cancel: 'export_task:package_cancel'
|
||||
}
|
||||
},
|
||||
agent_wallet_transaction: {
|
||||
scene: 'agent_wallet_transaction',
|
||||
sceneName: '代理主钱包流水',
|
||||
pageTitle: '导出代理主钱包流水',
|
||||
permissions: {
|
||||
detail: 'export_task:agent_wallet_transaction_detail',
|
||||
download: 'export_task:agent_wallet_transaction_download',
|
||||
cancel: 'export_task:agent_wallet_transaction_cancel'
|
||||
}
|
||||
},
|
||||
agent_recharge: {
|
||||
scene: 'agent_recharge',
|
||||
sceneName: '代理充值',
|
||||
pageTitle: '导出代理充值',
|
||||
permissions: {
|
||||
detail: 'export_task:agent_recharge_detail',
|
||||
download: 'export_task:agent_recharge_download',
|
||||
cancel: 'export_task:agent_recharge_cancel'
|
||||
}
|
||||
},
|
||||
refund: {
|
||||
scene: 'refund',
|
||||
sceneName: '退款',
|
||||
pageTitle: '导出退款',
|
||||
permissions: {
|
||||
detail: 'export_task:refund_detail',
|
||||
download: 'export_task:refund_download',
|
||||
cancel: 'export_task:refund_cancel'
|
||||
}
|
||||
},
|
||||
exchange: {
|
||||
scene: 'exchange',
|
||||
sceneName: '换货',
|
||||
pageTitle: '导出换货',
|
||||
permissions: {
|
||||
detail: 'export_task:exchange_detail',
|
||||
download: 'export_task:exchange_download',
|
||||
cancel: 'export_task:exchange_cancel'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,3 +37,4 @@ export * from './exportTask'
|
||||
|
||||
// 批量订购相关
|
||||
export * from './bulkPurchase'
|
||||
export * from './julyIteration'
|
||||
|
||||
32
src/config/constants/julyIteration.ts
Normal file
32
src/config/constants/julyIteration.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 七月迭代新增后台权限编码。
|
||||
* 页面和按钮统一引用这里的常量,后端菜单权限可直接复用同名编码。
|
||||
*/
|
||||
export const JULY_PERMISSIONS = {
|
||||
wecom: {
|
||||
page: 'wecom:config',
|
||||
application: 'wecom:application',
|
||||
member: 'wecom:member',
|
||||
scene: 'wecom:scene',
|
||||
binding: 'wecom:account_binding'
|
||||
},
|
||||
systemConfig: {
|
||||
payment: 'system_config:payment_methods'
|
||||
},
|
||||
speedTier: {
|
||||
view: 'iot_card:speed_tier',
|
||||
update: 'iot_card:speed_tier:update'
|
||||
},
|
||||
deviceAllocation: {
|
||||
page: 'device_task:allocation',
|
||||
create: 'device_task:allocation:create',
|
||||
detail: 'device_task:allocation:detail'
|
||||
},
|
||||
batchPurchase: {
|
||||
page: 'bulk_purchase:view',
|
||||
create: 'bulk_purchase:create',
|
||||
detail: 'bulk_purchase:detail'
|
||||
}
|
||||
} as const
|
||||
|
||||
export type JulyPermission = string
|
||||
@@ -39,27 +39,6 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
]
|
||||
},
|
||||
|
||||
// 临期资产
|
||||
{
|
||||
path: '/operations',
|
||||
name: 'Operations',
|
||||
component: RoutesAlias.Home,
|
||||
meta: {
|
||||
title: '运营管理'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'expiring-assets',
|
||||
name: 'ExpiringAssets',
|
||||
component: RoutesAlias.ExpiringAssets,
|
||||
meta: {
|
||||
title: '临期资产',
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 仪表台
|
||||
{
|
||||
name: 'Dashboard',
|
||||
@@ -352,6 +331,16 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
// 临期资产
|
||||
{
|
||||
path: 'expiring-assets',
|
||||
name: 'ExpiringAssets',
|
||||
component: RoutesAlias.ExpiringAssets,
|
||||
meta: {
|
||||
title: '临期资产',
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
// IoT卡管理
|
||||
{
|
||||
path: 'iot-card-management',
|
||||
@@ -775,6 +764,72 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
keepAlive: true,
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wecom',
|
||||
name: 'WecomSettings',
|
||||
redirect: RoutesAlias.WecomApplications,
|
||||
meta: {
|
||||
title: '企业微信配置',
|
||||
isHide: true,
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
}
|
||||
},
|
||||
// 企业微信应用
|
||||
{
|
||||
path: 'wecom/applications',
|
||||
name: 'WecomApplications',
|
||||
component: RoutesAlias.WecomApplications,
|
||||
meta: {
|
||||
title: '企微应用管理',
|
||||
keepAlive: true,
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
}
|
||||
},
|
||||
// 企业微信应用配置详情
|
||||
{
|
||||
path: 'wecom/applications/create',
|
||||
name: 'WecomApplicationCreate',
|
||||
component: RoutesAlias.WecomApplicationDetail,
|
||||
meta: {
|
||||
title: '新增企微应用',
|
||||
isHide: true,
|
||||
keepAlive: false,
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'wecom/applications/detail/:id',
|
||||
name: 'WecomApplicationDetail',
|
||||
component: RoutesAlias.WecomApplicationDetail,
|
||||
meta: {
|
||||
title: '企微应用配置',
|
||||
isHide: true,
|
||||
keepAlive: false,
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
}
|
||||
},
|
||||
// 企业微信成员及默认发起人
|
||||
{
|
||||
path: 'wecom/members',
|
||||
name: 'WecomMembers',
|
||||
component: RoutesAlias.WecomMembers,
|
||||
meta: {
|
||||
title: '企微成员管理',
|
||||
keepAlive: true,
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
}
|
||||
},
|
||||
// 企业微信审批场景
|
||||
{
|
||||
path: 'wecom/scenes',
|
||||
name: 'WecomScenes',
|
||||
component: RoutesAlias.WecomScenes,
|
||||
meta: {
|
||||
title: '企微审批场景',
|
||||
keepAlive: true,
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -80,7 +80,7 @@ export enum RoutesAlias {
|
||||
|
||||
// 站内通知
|
||||
Notifications = '/notifications', // 通知中心
|
||||
ExpiringAssets = '/operations/expiring-assets', // 临期资产
|
||||
ExpiringAssets = '/asset-management/expiring-assets', // 临期资产
|
||||
|
||||
// 佣金管理
|
||||
WithdrawalApproval = '/commission-management/withdrawal-approval', // 提现审批
|
||||
@@ -93,6 +93,11 @@ export enum RoutesAlias {
|
||||
PaymentSettingsDetail = '/settings/payment-settings/detail', // 支付设置详情
|
||||
OperationPasswordSettings = '/settings/operation-password', // 操作密码设置
|
||||
SystemConfigs = '/settings/system-configs', // 系统配置
|
||||
WecomSettings = '/settings/wecom', // 企业微信配置兼容入口
|
||||
WecomApplications = '/settings/wecom/applications', // 企业微信应用
|
||||
WecomApplicationDetail = '/settings/wecom/applications/detail', // 企业微信应用配置
|
||||
WecomMembers = '/settings/wecom/members', // 企业微信成员
|
||||
WecomScenes = '/settings/wecom/scenes', // 企业微信审批场景
|
||||
|
||||
// 轮询管理
|
||||
DataCleanup = '/polling-management/data-cleanup', // 数据清理
|
||||
|
||||
@@ -8,7 +8,8 @@ export enum AgentRechargeStatus {
|
||||
PAID = 2, // 已支付
|
||||
COMPLETED = 3, // 已完成
|
||||
CLOSED = 4, // 已关闭
|
||||
REFUNDED = 5 // 已退款
|
||||
REFUNDED = 5, // 已退款
|
||||
REJECTED = 6 // 已驳回
|
||||
}
|
||||
|
||||
// 支付方式
|
||||
@@ -35,8 +36,10 @@ export interface AgentRecharge {
|
||||
rejection_reason?: string | null // 拒绝原因
|
||||
remark?: string // 运营备注
|
||||
submitter_name?: string | null // 提交人名称
|
||||
approval_source?: 'none' | 'legacy' | 'wecom' | null // 审批来源
|
||||
approval_status?: string | null // 审批状态
|
||||
submitter_id?: number | null
|
||||
approval_provider?: 'wecom' | null
|
||||
approval_instance_id?: number | null
|
||||
approval_status?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | null // 企微审批状态
|
||||
approval_status_name?: string | null // 审批状态名称
|
||||
current_approver_summary?: string | null // 当前审批人摘要
|
||||
processing_status?: string | null // 业务处理状态
|
||||
@@ -55,7 +58,7 @@ export interface AgentRechargeQueryParams {
|
||||
status?: AgentRechargeStatus
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
dateRange?: string[] | any // For date range picker in UI
|
||||
dateRange?: string[] // For date range picker in UI
|
||||
}
|
||||
|
||||
// 代理充值订单列表响应
|
||||
|
||||
@@ -10,6 +10,8 @@ type ExpiryBase = 'from_activation' | 'from_purchase'
|
||||
|
||||
export type AssetType = 'card' | 'device'
|
||||
|
||||
import type { AllowedPaymentMethod } from './systemConfig'
|
||||
|
||||
// 网络状态
|
||||
export enum NetworkStatus {
|
||||
OFFLINE = 0, // 停机
|
||||
@@ -104,6 +106,11 @@ export interface BatchUpdateAssetRealnamePolicyRequest {
|
||||
realname_policy: AssetRealnamePolicy
|
||||
}
|
||||
|
||||
export interface BatchUpdateAssetRealnamePolicyResponse {
|
||||
realname_policy: AssetRealnamePolicy
|
||||
success_count: number
|
||||
}
|
||||
|
||||
// 换货关联资产
|
||||
export interface AssetExchangeTraceAsset {
|
||||
asset_type: string
|
||||
@@ -135,7 +142,10 @@ export interface AssetResolveResponse {
|
||||
series_id: number // 套餐系列 ID
|
||||
series_name: string // 套餐系列名称
|
||||
real_name_status: RealNameStatus // 实名状态:0 未实名 / 1 已实名
|
||||
realname_policy?: string // 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
|
||||
realname_policy?: AssetRealnamePolicy // 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
|
||||
effective_realname_policy?: AssetRealnamePolicy
|
||||
realname_required?: boolean
|
||||
allowed_payment_methods?: AllowedPaymentMethod[]
|
||||
real_name_at?: string | null // 实名时间
|
||||
network_status?: NetworkStatus // 网络状态:0 停机 / 1 开机(仅 card)
|
||||
current_package: string // 当前套餐名称(无则空)
|
||||
@@ -206,7 +216,7 @@ export interface AssetBoundCard {
|
||||
network_status: NetworkStatus // 网络状态
|
||||
real_name_status: RealNameStatus // 实名状态
|
||||
real_name_at?: string | null // 实名时间
|
||||
realname_policy?: string // 实名认证策略
|
||||
realname_policy?: AssetRealnamePolicy // 实名认证策略
|
||||
slot_position: number // 插槽位置
|
||||
is_current?: boolean // 是否为设备当前使用的卡
|
||||
gateway_extend?: string // Gateway 卡状态扩展字段,原样返回上游 extend
|
||||
@@ -267,7 +277,7 @@ export interface AssetRealtimeStatusResponse {
|
||||
// ===== 卡专属字段 =====
|
||||
network_status?: NetworkStatus // 网络状态(仅 card)
|
||||
real_name_status?: RealNameStatus // 实名状态(仅 card)
|
||||
realname_policy?: string // 实名认证策略(仅 card)
|
||||
realname_policy?: AssetRealnamePolicy // 实名认证策略(仅 card)
|
||||
gateway_card_imei?: string // Gateway 返回的卡 IMEI
|
||||
current_month_usage_mb?: number // 本月已用流量 MB(仅 card)
|
||||
last_gateway_reading_mb?: number // 运营商周期月内已用流量 MB(仅 card)
|
||||
|
||||
@@ -2,6 +2,13 @@ import type { BaseResponse } from './common'
|
||||
|
||||
export type BulkPurchasePaymentMethod = 'wallet' | 'offline'
|
||||
|
||||
export interface BulkPurchaseCreateRequest {
|
||||
file_key: string
|
||||
package_id: number
|
||||
payment_method: BulkPurchasePaymentMethod
|
||||
voucher_keys?: string[]
|
||||
}
|
||||
|
||||
export enum BulkPurchaseTaskStatus {
|
||||
PENDING = 1,
|
||||
PROCESSING = 2,
|
||||
@@ -19,37 +26,46 @@ export interface BulkPurchaseCreateResponse {
|
||||
}
|
||||
|
||||
export interface BulkPurchaseTask {
|
||||
id?: number
|
||||
task_id: number
|
||||
task_no?: string
|
||||
id: number
|
||||
task_id?: number
|
||||
task_no: string
|
||||
file_name: string
|
||||
package_id: number
|
||||
package_code: string
|
||||
package_name: string
|
||||
payment_method: BulkPurchasePaymentMethod
|
||||
status: BulkPurchaseTaskStatus
|
||||
status_name?: string
|
||||
total_count?: number
|
||||
success_count?: number
|
||||
status_name: string
|
||||
total_count: number
|
||||
success_count: number
|
||||
fail_count: number
|
||||
failed_count?: number
|
||||
total_amount?: number
|
||||
success_amount?: number
|
||||
failed_amount?: number
|
||||
amount_summary?: Record<string, number | string>
|
||||
error_code?: string
|
||||
error_summary?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
error_message: string
|
||||
creator_name: string
|
||||
voucher_keys: string[] | null
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
items?: BulkPurchaseItem[]
|
||||
}
|
||||
|
||||
export interface BulkPurchaseItem {
|
||||
id?: number
|
||||
row_number?: number
|
||||
line?: number
|
||||
asset_identifier?: string
|
||||
line: number
|
||||
asset_identifier: string
|
||||
package_code?: string
|
||||
status: BulkPurchaseTaskStatus
|
||||
status_name: string
|
||||
order_id: number
|
||||
order_no: string
|
||||
amount: number
|
||||
reason: string
|
||||
iccid?: string
|
||||
virtual_no?: string
|
||||
package_code?: string
|
||||
status?: string | number
|
||||
status_name?: string
|
||||
error_code?: string
|
||||
error_reason?: string
|
||||
error_summary?: string
|
||||
}
|
||||
@@ -64,4 +80,11 @@ export interface BulkPurchaseItemsResponse {
|
||||
|
||||
export type BulkPurchaseCreateApiResponse = BaseResponse<BulkPurchaseCreateResponse>
|
||||
export type BulkPurchaseTaskApiResponse = BaseResponse<BulkPurchaseTask>
|
||||
export type BulkPurchaseItemsApiResponse = BaseResponse<BulkPurchaseItemsResponse>
|
||||
export interface BulkPurchaseTaskListResponse {
|
||||
items: BulkPurchaseTask[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type BulkPurchaseTaskListApiResponse = BaseResponse<BulkPurchaseTaskListResponse>
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
import { PaginationParams, ImportTask } from './common'
|
||||
import type { ExpiryEstimateStatus } from './asset'
|
||||
|
||||
export type SpeedTierCode = -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
|
||||
|
||||
export interface SetSpeedTierResponse {
|
||||
code: SpeedTierCode
|
||||
iccid: string
|
||||
integration_id: string
|
||||
iot_card_id: number
|
||||
speed_tier_name: string
|
||||
}
|
||||
|
||||
// 运营商类型
|
||||
export enum Operator {
|
||||
CHINA_MOBILE = 'mobile', // 中国移动
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { PaginationParams } from './common'
|
||||
import type { ExpiryEstimateStatus } from './asset'
|
||||
import type { AssetRealnamePolicy, ExpiryEstimateStatus } from './asset'
|
||||
|
||||
// ========== 设备状态枚举 ==========
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface Device {
|
||||
last_gateway_sync_at?: string | null // 最后 sync-info 同步时间
|
||||
imei?: string // IMEI(设备国际移动设备识别码)
|
||||
sn?: string // 设备序列号
|
||||
realname_policy?: string // 实名认证策略
|
||||
realname_policy?: AssetRealnamePolicy // 实名认证策略
|
||||
real_name_status: 0 | 1 // 实名状态 (0:未实名, 1:已实名)
|
||||
real_name_status_name: string // 实名状态名称
|
||||
estimated_final_expires_at?: string | null // 当前及排队主套餐接续后的预计最终到期时间
|
||||
@@ -217,6 +217,7 @@ export enum DeviceImportTaskStatus {
|
||||
// 导入任务查询参数
|
||||
export interface DeviceImportTaskQueryParams extends PaginationParams {
|
||||
status?: DeviceImportTaskStatus // 任务状态
|
||||
operation_type?: 'import' | 'assign_shop' | 'assign_series'
|
||||
batch_no?: string // 批次号(模糊查询)
|
||||
start_time?: string // 创建时间起始
|
||||
end_time?: string // 创建时间结束
|
||||
@@ -237,6 +238,11 @@ export interface DeviceImportTask {
|
||||
fail_count: number // 失败数
|
||||
skip_count: number // 跳过数
|
||||
error_message: string // 错误信息
|
||||
operation_type: 'import' | 'assign_shop' | 'assign_series'
|
||||
operation_name: string
|
||||
target_id: number | null
|
||||
realname_policy: 'none' | 'before_order' | 'after_order'
|
||||
warning_count: number
|
||||
created_at: string // 创建时间
|
||||
started_at: string | null // 开始处理时间
|
||||
completed_at: string | null // 完成时间
|
||||
@@ -244,17 +250,17 @@ export interface DeviceImportTask {
|
||||
|
||||
// 导入任务列表响应
|
||||
export interface DeviceImportTaskListResponse {
|
||||
list: DeviceImportTask[] | null // 任务列表
|
||||
items: DeviceImportTask[] // 任务列表
|
||||
page: number // 当前页码
|
||||
page_size: number // 每页数量
|
||||
size: number // 每页数量
|
||||
total: number // 总数
|
||||
total_pages: number // 总页数
|
||||
}
|
||||
|
||||
// 导入结果详细项
|
||||
export interface DeviceImportResultItem {
|
||||
line: number // 行号
|
||||
virtual_no: string // 虚拟号(原 device_no)
|
||||
device_identifier: string // CSV 原始设备标识
|
||||
virtual_no: string // 设备虚拟号
|
||||
reason: string // 原因
|
||||
}
|
||||
|
||||
@@ -262,6 +268,19 @@ export interface DeviceImportResultItem {
|
||||
export interface DeviceImportTaskDetail extends DeviceImportTask {
|
||||
failed_items: DeviceImportResultItem[] | null // 失败记录详情
|
||||
skipped_items: DeviceImportResultItem[] | null // 跳过记录详情
|
||||
warning_items: DeviceImportResultItem[] | null // 警告记录详情
|
||||
}
|
||||
|
||||
export interface DeviceBatchAllocationRequest {
|
||||
file_key: string
|
||||
operation_type: 'assign_shop' | 'assign_series'
|
||||
target_id: number
|
||||
}
|
||||
|
||||
export interface DeviceBatchAllocationResponse {
|
||||
message: string
|
||||
task_id: number
|
||||
task_no: string
|
||||
}
|
||||
|
||||
// ========== 批量设置设备的套餐系列绑定相关 ==========
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { BaseResponse, PaginationParams } from './common'
|
||||
|
||||
export type ExportTaskScene = 'device' | 'iot_card' | 'order'
|
||||
export type ExportTaskScene =
|
||||
| 'device'
|
||||
| 'iot_card'
|
||||
| 'order'
|
||||
| 'package'
|
||||
| 'agent_wallet_transaction'
|
||||
| 'agent_recharge'
|
||||
| 'refund'
|
||||
| 'exchange'
|
||||
|
||||
export type ExportTaskFormat = 'xlsx' | 'csv'
|
||||
|
||||
@@ -45,6 +53,10 @@ export interface ExportTaskItem {
|
||||
created_at: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
creator_enterprise_id?: number | null
|
||||
creator_shop_id?: number | null
|
||||
creator_user_id?: number
|
||||
creator_user_type?: number
|
||||
}
|
||||
|
||||
export interface ExportTaskListResponse {
|
||||
|
||||
@@ -125,3 +125,6 @@ export * from './bulkPurchase'
|
||||
|
||||
// 站内通知相关
|
||||
export * from './notification'
|
||||
|
||||
// 企业微信审批配置相关
|
||||
export * from './wecom'
|
||||
|
||||
@@ -22,6 +22,14 @@ export type OrderPaymentMethod = 'wallet' | 'wechat' | 'alipay' | 'offline'
|
||||
// 订单操作者类型
|
||||
export type OrderOperatorType = 'platform' | 'agent' | 'enterprise' | 'personal_customer'
|
||||
|
||||
export type PurchaseRole =
|
||||
| 'self_purchase'
|
||||
| 'purchased_by_parent'
|
||||
| 'purchased_by_platform'
|
||||
| 'purchase_for_subordinate'
|
||||
|
||||
export type OrderAssetType = 'card' | 'device'
|
||||
|
||||
// 订单佣金流程状态
|
||||
export enum OrderCommissionStatus {
|
||||
PENDING = 1, // 待计算
|
||||
@@ -71,9 +79,10 @@ export interface Order {
|
||||
device_id: number | null
|
||||
iot_card_id: number | null
|
||||
asset_identifier?: string // 资产标识符快照(ICCID 或 VirtualNo)
|
||||
asset_type?: string // 资产类型:single_card 或 device
|
||||
purchase_role?: string // 订单渠道
|
||||
asset_type?: OrderAssetType // 资产类型:card 或 device
|
||||
purchase_role?: PurchaseRole // 订单渠道
|
||||
purchase_remark?: string // 购买备注
|
||||
is_purchase_on_behalf?: boolean // 是否为代购订单
|
||||
is_purchased_by_parent?: boolean // 是否由上级代购
|
||||
is_expired?: boolean // 是否已过期
|
||||
expires_at?: string | null // 订单超时时间
|
||||
@@ -97,13 +106,13 @@ export interface OrderQueryParams {
|
||||
order_type?: OrderType
|
||||
order_no?: string
|
||||
identifier?: string // 按资产标识符过滤(ICCID 或 VirtualNo)
|
||||
purchase_role?: string // 订单渠道
|
||||
purchase_role?: PurchaseRole // 订单渠道
|
||||
buyer_phone?: string // 按买家手机号精确过滤
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
is_expired?: boolean // 是否已过期
|
||||
seller_shop_id?: number // 所属代理商ID(销售来源店铺ID)
|
||||
dateRange?: string[] | any // For date range picker in UI
|
||||
dateRange?: string[] // For date range picker in UI
|
||||
}
|
||||
|
||||
// 订单列表响应
|
||||
|
||||
@@ -171,7 +171,7 @@ export interface PackageResponse extends PackageAllocationExpiryBaseFields {
|
||||
suggested_retail_price?: number | null // 建议零售价(分),可能为 null 表示"未配置"
|
||||
retail_price?: number | null // 原始代理零售价(分),可能为 null
|
||||
is_gift?: boolean // 是否为赠送套餐
|
||||
price_config_status?: string // 价格配置状态
|
||||
price_config_status?: number // 价格配置状态:0未配置,1赠送0价,2已配置非0
|
||||
price_config_status_name?: string // 价格配置状态名称
|
||||
effective_retail_price?: number | null // 有效零售价(分)
|
||||
retail_price_config_status?: string // 零售价配置状态(代理视角)
|
||||
@@ -180,7 +180,7 @@ export interface PackageResponse extends PackageAllocationExpiryBaseFields {
|
||||
profit_margin?: number | null // 利润空间(分,仅代理用户可见)
|
||||
tier_info?: CommissionTierInfo // 返佣档位信息
|
||||
shelf_status?: number // 上架状态 (1:上架, 2:下架)
|
||||
status?: number // 状态 (1:启用, 2:禁用)
|
||||
status?: number // 状态 (0:禁用, 1:启用)
|
||||
description?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
@@ -193,7 +193,6 @@ export interface PackageQueryParams extends PaginationParams {
|
||||
package_name?: string // 套餐名称(模糊搜索)
|
||||
series_id?: number // 系列ID
|
||||
package_type?: string // 套餐类型
|
||||
data_type?: string // 流量类型
|
||||
shelf_status?: number // 上架状态
|
||||
status?: number // 状态
|
||||
}
|
||||
@@ -244,7 +243,7 @@ export interface UpdatePackageRequest {
|
||||
* 更新套餐状态请求
|
||||
*/
|
||||
export interface UpdatePackageStatusRequest {
|
||||
status: number // 1:启用, 2:禁用
|
||||
status: number // 0:禁用, 1:启用
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,8 +66,11 @@ export interface Refund {
|
||||
asset_reset: boolean
|
||||
commission_deducted: boolean
|
||||
submitter_name?: string | null // 提交人名称
|
||||
submitter_id?: number | null
|
||||
approval_provider?: 'wecom' | null
|
||||
approval_instance_id?: number | null
|
||||
approval_source?: 'none' | 'legacy' | 'wecom' | null // 审批来源
|
||||
approval_status?: string | null // 审批状态
|
||||
approval_status?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | null // 审批状态
|
||||
approval_status_name?: string | null // 审批状态名称
|
||||
current_approver_summary?: string | null // 当前审批人摘要
|
||||
processing_status?: string | null // 业务处理状态
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface ShopResponse {
|
||||
business_owner_username: string // 平台业务员账号名
|
||||
business_owner_phone_summary: string // 平台业务员手机号摘要
|
||||
business_owner_available: boolean // 是否仍可用于通知接收
|
||||
client_login_disabled: boolean // 是否禁止 C 端新登录
|
||||
}
|
||||
|
||||
// 店铺列表查询参数
|
||||
@@ -39,6 +40,7 @@ export interface ShopQueryParams extends PaginationParams {
|
||||
status?: number | null // 状态 (0:禁用, 1:启用)
|
||||
contact_phone?: string // 联系电话精确查询
|
||||
business_owner_account_id?: number | null // 平台业务员账号ID
|
||||
client_login_disabled?: boolean
|
||||
page?: number // 页码
|
||||
page_size?: number // 每页数量
|
||||
}
|
||||
@@ -59,6 +61,7 @@ export interface CreateShopParams {
|
||||
contact_name?: string // 联系人姓名
|
||||
contact_phone?: string // 联系人电话
|
||||
business_owner_account_id?: number | null // 平台业务员账号ID
|
||||
client_login_disabled?: boolean
|
||||
}
|
||||
|
||||
// 更新店铺参数
|
||||
@@ -72,6 +75,7 @@ export interface UpdateShopParams {
|
||||
contact_name?: string // 联系人姓名
|
||||
contact_phone?: string // 联系人电话
|
||||
business_owner_account_id?: number | null // 平台业务员账号ID
|
||||
client_login_disabled?: boolean
|
||||
}
|
||||
|
||||
// 店铺业务员候选
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { PaginationParams } from './common'
|
||||
|
||||
export type SystemConfigModule = 'carrier_callback' | 'c2b.payment'
|
||||
|
||||
export type AllowedPaymentMethod = 'wallet' | 'wechat' | 'alipay'
|
||||
export const PAYMENT_CONFIG_KEYS = {
|
||||
card: 'c2b.payment.card_allowed_methods',
|
||||
device: 'c2b.payment.device_allowed_methods'
|
||||
} as const
|
||||
|
||||
export type SystemConfigValueType = 'string' | 'int' | 'bool' | 'json'
|
||||
|
||||
export interface SystemConfigItem {
|
||||
@@ -35,3 +41,16 @@ export interface UpdateSystemConfigRequest {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function parsePaymentMethods(value: string): AllowedPaymentMethod[] {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((method): method is AllowedPaymentMethod =>
|
||||
['wallet', 'wechat', 'alipay'].includes(method)
|
||||
)
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
95
src/types/api/wecom.ts
Normal file
95
src/types/api/wecom.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { BaseResponse, PaginationData, PaginationParams } from './common'
|
||||
|
||||
export type WecomBusinessType = 'refund_approval' | 'offline_recharge_approval'
|
||||
|
||||
export interface WecomApplication {
|
||||
id: number
|
||||
corp_id: string
|
||||
agent_id: number
|
||||
name: string
|
||||
secret?: string
|
||||
callback_token?: string
|
||||
encoding_aes_key?: string
|
||||
credentials_set: boolean
|
||||
default_creator_userid?: string | null
|
||||
default_creator_name?: string | null
|
||||
status: 0 | 1
|
||||
status_name: string
|
||||
last_connected_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WecomApplicationRequest {
|
||||
corp_id?: string
|
||||
agent_id?: number
|
||||
name?: string
|
||||
secret?: string
|
||||
callback_token?: string
|
||||
encoding_aes_key?: string
|
||||
status?: 0 | 1
|
||||
}
|
||||
|
||||
export interface WecomMember {
|
||||
application_id: number
|
||||
corp_id: string
|
||||
userid: string
|
||||
name: string
|
||||
department_ids: number[] | null
|
||||
synced_at: string
|
||||
}
|
||||
|
||||
export interface WecomSceneControlMapping {
|
||||
business_field: string
|
||||
control_id: string
|
||||
control_type: string
|
||||
option_mapping: Record<string, string>
|
||||
}
|
||||
|
||||
export interface WecomScene {
|
||||
id: number
|
||||
application_id: number
|
||||
business_type: WecomBusinessType
|
||||
business_type_name: string
|
||||
template_id: string
|
||||
template_name: string
|
||||
control_mapping: WecomSceneControlMapping[]
|
||||
template_fingerprint: string
|
||||
last_verified_at: string
|
||||
status: 0 | 1
|
||||
status_name: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WecomSceneRequest {
|
||||
application_id?: number
|
||||
template_id?: string
|
||||
control_mapping?: WecomSceneControlMapping[]
|
||||
status?: 0 | 1
|
||||
}
|
||||
|
||||
export interface WecomAccountBindingRequest {
|
||||
application_id: number
|
||||
userid: string
|
||||
}
|
||||
|
||||
export interface WecomSyncMembersResponse {
|
||||
application_id: number
|
||||
synced_at: string
|
||||
synced_count: number
|
||||
}
|
||||
|
||||
export type WecomApplicationListResponse = BaseResponse<PaginationData<WecomApplication>>
|
||||
export type WecomMemberListResponse = BaseResponse<PaginationData<WecomMember>>
|
||||
export type WecomSceneListResponse = BaseResponse<PaginationData<WecomScene>>
|
||||
export type WecomApplicationResponse = BaseResponse<WecomApplication>
|
||||
export type WecomSceneResponse = BaseResponse<WecomScene>
|
||||
export type WecomSyncMembersApiResponse = BaseResponse<WecomSyncMembersResponse>
|
||||
|
||||
export interface WecomMemberQueryParams extends PaginationParams {
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface WecomApplicationQueryParams extends PaginationParams {}
|
||||
|
||||
export interface WecomSceneQueryParams extends PaginationParams {}
|
||||
1
src/types/components.d.ts
vendored
1
src/types/components.d.ts
vendored
@@ -91,6 +91,7 @@ declare module 'vue' {
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
|
||||
31
src/utils/business/apiError.ts
Normal file
31
src/utils/business/apiError.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export type ApiErrorKind = 'auth' | 'forbidden' | 'validation' | 'conflict' | 'timeout' | 'server' | 'unknown'
|
||||
|
||||
export interface NormalizedApiError {
|
||||
kind: ApiErrorKind
|
||||
status?: number
|
||||
code?: number | string
|
||||
message: string
|
||||
unknownResult: boolean
|
||||
}
|
||||
|
||||
const getResponse = (error: unknown) => (axios.isAxiosError(error) ? error.response : undefined)
|
||||
|
||||
export const normalizeApiError = (error: unknown): NormalizedApiError => {
|
||||
const response = getResponse(error)
|
||||
const status = response?.status
|
||||
const code = response?.data?.code
|
||||
const backendMessage = response?.data?.msg
|
||||
const timeout = axios.isAxiosError(error) && error.code === 'ECONNABORTED'
|
||||
|
||||
if (timeout) {
|
||||
return { kind: 'timeout', status, code, message: '请求超时,结果可能未知,请刷新确认', unknownResult: true }
|
||||
}
|
||||
if (status === 401 || code === 401) return { kind: 'auth', status, code, message: '登录状态已失效,请重新登录', unknownResult: false }
|
||||
if (status === 403) return { kind: 'forbidden', status, code, message: '没有执行此操作的权限', unknownResult: false }
|
||||
if (status === 400) return { kind: 'validation', status, code, message: backendMessage || '请求参数或业务条件不符合要求', unknownResult: false }
|
||||
if (status === 409) return { kind: 'conflict', status, code, message: backendMessage || '数据已发生变化,请刷新后重试', unknownResult: false }
|
||||
if (status && status >= 500) return { kind: 'server', status, code, message: '服务器处理失败,请稍后重试', unknownResult: false }
|
||||
return { kind: 'unknown', status, code, message: backendMessage || '操作失败,请稍后重试', unknownResult: false }
|
||||
}
|
||||
@@ -2,6 +2,9 @@ export type ApprovalSource = 'none' | 'legacy' | 'wecom'
|
||||
|
||||
export interface ApprovalSummary {
|
||||
approval_source?: ApprovalSource | null
|
||||
approval_provider?: 'wecom' | null
|
||||
approval_instance_id?: number | null
|
||||
approval_status?: number | string | null
|
||||
approval_status_name?: string | null
|
||||
current_approver_summary?: string | null
|
||||
processing_status_name?: string | null
|
||||
@@ -9,14 +12,36 @@ export interface ApprovalSummary {
|
||||
|
||||
export const getApprovalStatusText = (summary: ApprovalSummary): string => {
|
||||
if (summary.approval_source === 'legacy') return '历史审批'
|
||||
if (summary.approval_source === 'wecom') return summary.approval_status_name || '-'
|
||||
if (
|
||||
summary.approval_source === 'wecom' ||
|
||||
summary.approval_provider === 'wecom' ||
|
||||
summary.approval_instance_id
|
||||
) {
|
||||
return summary.approval_status_name || approvalStatusNames[Number(summary.approval_status)] || '-'
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
export const getCurrentApproverSummaryText = (summary: ApprovalSummary): string => {
|
||||
if (summary.approval_source !== 'wecom') return '-'
|
||||
if (
|
||||
summary.approval_source !== 'wecom' &&
|
||||
summary.approval_provider !== 'wecom' &&
|
||||
!summary.approval_instance_id
|
||||
) return '-'
|
||||
return summary.current_approver_summary || '-'
|
||||
}
|
||||
|
||||
export const getProcessingStatusText = (summary: ApprovalSummary): string =>
|
||||
summary.processing_status_name || '-'
|
||||
|
||||
export const approvalStatusNames: Record<number, string> = {
|
||||
0: '提交中',
|
||||
1: '审批中',
|
||||
2: '已通过',
|
||||
3: '已拒绝',
|
||||
4: '已撤销',
|
||||
5: '通过后撤销',
|
||||
6: '已删除',
|
||||
7: '提交失败',
|
||||
8: '提交结果未知'
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { ApiStatus } from './status'
|
||||
import type { RequestOptions, ErrorMessageMode } from '@/types/api'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
timeout: 15000, // 请求超时时间(毫秒)
|
||||
@@ -220,7 +221,8 @@ function handleErrorMessage(
|
||||
) {
|
||||
if (mode === 'none') return
|
||||
|
||||
const backendMessage = error.response?.data?.msg
|
||||
const normalized = normalizeApiError(error)
|
||||
const backendMessage = normalized.message
|
||||
const httpStatus = error.response?.status
|
||||
|
||||
// 401 过期类错误由 clearLocalStateAndRedirect 统一提示,避免重复弹出后端报错。
|
||||
@@ -245,7 +247,7 @@ function handleErrorMessage(
|
||||
}
|
||||
|
||||
// 其他错误显示通用提示
|
||||
const message = '请求超时或服务器异常!'
|
||||
const message = normalized.message
|
||||
if (mode === 'modal') {
|
||||
// TODO: 可以使用 ElMessageBox 显示模态框
|
||||
ElMessage.error(message)
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="card-header-right">
|
||||
<ElButton
|
||||
v-if="cardInfo?.asset_type === 'card' && hasAuth(JULY_PERMISSIONS.speedTier.view)"
|
||||
type="primary"
|
||||
link
|
||||
@click="emit('showSpeedLimit')"
|
||||
>
|
||||
设置限速
|
||||
</ElButton>
|
||||
<ElTooltip content="开启后系统将自动轮询更新资产状态" placement="top">
|
||||
<div v-if="canShowPolling" class="polling-switch-wrapper">
|
||||
<span class="polling-label">自动轮询</span>
|
||||
@@ -194,33 +202,6 @@
|
||||
</template>
|
||||
</ElDescriptions>
|
||||
|
||||
<ElDivider content-position="left">同步状态</ElDivider>
|
||||
<ElDescriptions :column="descriptionsColumn" border>
|
||||
<ElDescriptionsItem label="轮询状态">
|
||||
<ElTag
|
||||
v-if="cardInfo?.polling"
|
||||
:type="cardInfo.polling.enabled ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ cardInfo.polling.enabled ? '已启用' : '未启用' }}
|
||||
</ElTag>
|
||||
<span v-else>-</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="最后活跃时间">
|
||||
{{ formatDateTime(cardInfo?.polling?.last_activity_at) || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="同步轨迹">
|
||||
<ElButton
|
||||
v-permission="'asset_info:view_sync_trail'"
|
||||
type="primary"
|
||||
link
|
||||
@click="emit('viewSyncTrail')"
|
||||
>
|
||||
查看同步轨迹
|
||||
</ElButton>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<template v-if="previousExchangeAsset || nextExchangeAsset">
|
||||
<ElDivider content-position="left">换货链路</ElDivider>
|
||||
<ElDescriptions :column="descriptionsColumn" border>
|
||||
@@ -596,6 +577,7 @@
|
||||
} from '@/types/api'
|
||||
import { useAssetFormatters } from '../composables/useAssetFormatters'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
@@ -798,7 +780,6 @@
|
||||
(e: 'navigateToCard', iccid: string): void
|
||||
(e: 'navigateToDevice', deviceNo: string): void
|
||||
(e: 'navigateToAsset', identifier: string): void
|
||||
(e: 'viewSyncTrail'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
@@ -336,7 +337,11 @@
|
||||
data.payment_voucher_key = toVoucherKeyList(form.payment_voucher_key)
|
||||
}
|
||||
|
||||
await OrderService.createOrder(data)
|
||||
const response = await OrderService.createOrder(data)
|
||||
if (response.code !== 0) {
|
||||
ElMessage.error(response.msg || '订单创建失败')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.payment_method === 'wallet') {
|
||||
ElMessage.success('订单创建成功,已自动完成支付')
|
||||
@@ -352,6 +357,7 @@
|
||||
return
|
||||
}
|
||||
console.error('创建订单失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
<template>
|
||||
<ElDialog v-model="visible" title="设置限速" width="500px">
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<ElFormItem label="下行速率" prop="download_speed">
|
||||
<ElInputNumber
|
||||
v-model="form.download_speed"
|
||||
:min="1"
|
||||
:step="128"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div style="margin-top: 4px; font-size: 12px; color: #909399">单位: KB/s</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="上行速率" prop="upload_speed">
|
||||
<ElInputNumber
|
||||
v-model="form.upload_speed"
|
||||
:min="1"
|
||||
:step="128"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div style="margin-top: 4px; font-size: 12px; color: #909399">单位: KB/s</div>
|
||||
<ElFormItem label="固定档位" prop="code">
|
||||
<ElSelect v-model="form.code" style="width: 100%">
|
||||
<ElOption v-for="tier in speedTiers" :key="tier.code" :label="tier.label" :value="tier.code" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
@@ -31,7 +16,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ElDialog, ElForm, ElFormItem, ElInputNumber, ElButton } from 'element-plus'
|
||||
import { ElDialog, ElForm, ElFormItem, ElSelect, ElOption, ElButton } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
interface Props {
|
||||
@@ -40,7 +25,7 @@
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', data: { download_speed: number; upload_speed: number }): void
|
||||
(e: 'confirm', data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -49,14 +34,23 @@
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
download_speed: 1024,
|
||||
upload_speed: 512
|
||||
})
|
||||
const speedTiers = [
|
||||
{ code: -1, label: '不限速' },
|
||||
{ code: 0, label: '0kbps' },
|
||||
{ code: 1, label: '128Kbps' },
|
||||
{ code: 2, label: '512Kbps' },
|
||||
{ code: 3, label: '1Mbps' },
|
||||
{ code: 4, label: '2Mbps' },
|
||||
{ code: 5, label: '10Mbps' },
|
||||
{ code: 6, label: '20Mbps' },
|
||||
{ code: 7, label: '50Mbps' },
|
||||
{ code: 8, label: '100Mbps' }
|
||||
] as const
|
||||
|
||||
const form = reactive({ code: 3 as (typeof speedTiers)[number]['code'] })
|
||||
|
||||
const rules: FormRules = {
|
||||
download_speed: [{ required: true, message: '请输入下行速率', trigger: 'blur' }],
|
||||
upload_speed: [{ required: true, message: '请输入上行速率', trigger: 'blur' }]
|
||||
code: [{ required: true, message: '请选择限速档位', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const visible = computed({
|
||||
@@ -67,8 +61,7 @@
|
||||
// 监听对话框打开,重置表单
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal) {
|
||||
form.download_speed = 1024
|
||||
form.upload_speed = 512
|
||||
form.code = 3
|
||||
}
|
||||
})
|
||||
|
||||
@@ -82,8 +75,7 @@
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
emit('confirm', {
|
||||
download_speed: form.download_speed,
|
||||
upload_speed: form.upload_speed
|
||||
code: form.code
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error)
|
||||
|
||||
@@ -16,7 +16,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
|
||||
const manualDeactivateCardLoading = ref(false)
|
||||
const rebootDeviceLoading = ref(false)
|
||||
const resetDeviceLoading = ref(false)
|
||||
const speedLimitLoading = ref(false)
|
||||
const switchCardLoading = ref(false)
|
||||
const setWiFiLoading = ref(false)
|
||||
const pollingLoading = ref(false)
|
||||
@@ -181,35 +180,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set device speed limit
|
||||
*/
|
||||
const setSpeedLimit = async (form: { download_speed: number; upload_speed: number }) => {
|
||||
try {
|
||||
speedLimitLoading.value = true
|
||||
const res = await DeviceService.setSpeedLimit(cardInfo.value.imei, {
|
||||
download_speed: form.download_speed,
|
||||
upload_speed: form.upload_speed
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('限速设置成功')
|
||||
if (refreshAssetFn) {
|
||||
await refreshAssetFn()
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
ElMessage.error(res.msg || '设置失败')
|
||||
return false
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('设置限速失败:', error)
|
||||
console.log(error?.message || '设置失败')
|
||||
return false
|
||||
} finally {
|
||||
speedLimitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to different card
|
||||
*/
|
||||
@@ -320,7 +290,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
|
||||
manualDeactivateCardLoading,
|
||||
rebootDeviceLoading,
|
||||
resetDeviceLoading,
|
||||
speedLimitLoading,
|
||||
switchCardLoading,
|
||||
setWiFiLoading,
|
||||
pollingLoading,
|
||||
@@ -333,7 +302,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
|
||||
// Device operations
|
||||
rebootDevice,
|
||||
resetDevice,
|
||||
setSpeedLimit,
|
||||
switchCard,
|
||||
setWiFi,
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
@show-switch-mode="showSwitchModeDialog"
|
||||
@show-set-wifi="showSetWiFiDialog"
|
||||
@show-realname-policy="showRealnamePolicyDialog"
|
||||
@show-speed-limit="speedLimitDialogVisible = true"
|
||||
@show-update-realname-status="showUpdateRealnameStatusDialog"
|
||||
@enable-binding-card="handleEnableBindingCard"
|
||||
@disable-binding-card="handleDisableBindingCard"
|
||||
@@ -44,7 +45,6 @@
|
||||
@navigate-to-asset="handleNavigateToAsset"
|
||||
@navigate-to-card="handleNavigateToCard"
|
||||
@navigate-to-device="handleNavigateToDevice"
|
||||
@view-sync-trail="handleViewSyncTrail"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
:current-realname-status="bindingCardRealnameStatusValue"
|
||||
@success="handleBindingCardRealnameStatusSuccess"
|
||||
/>
|
||||
<SpeedLimitDialog v-model="speedLimitDialogVisible" @confirm="handleSpeedTierConfirm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -182,7 +183,7 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElCard, ElEmpty } from 'element-plus'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { DeviceService } from '@/api/modules'
|
||||
import { CardService, DeviceService } from '@/api/modules'
|
||||
import {
|
||||
formatRemainingTime,
|
||||
FrontendRateLimitError,
|
||||
@@ -204,7 +205,8 @@
|
||||
WiFiConfigDialog,
|
||||
PackageRechargeDialog,
|
||||
DailyRecordsDialog,
|
||||
OrderHistoryDialog
|
||||
OrderHistoryDialog,
|
||||
SpeedLimitDialog
|
||||
} from './components/dialogs'
|
||||
import SwitchCardDialog from '@/components/device/SwitchCardDialog.vue'
|
||||
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
|
||||
@@ -291,6 +293,7 @@
|
||||
|
||||
// 绑定卡实名状态更新
|
||||
const bindingCardRealnameStatusDialogVisible = ref(false)
|
||||
const speedLimitDialogVisible = ref(false)
|
||||
const bindingCardRealnameStatusIccid = ref('')
|
||||
const bindingCardRealnameStatusValue = ref<number>(0)
|
||||
|
||||
@@ -444,18 +447,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewSyncTrail = () => {
|
||||
if (!cardInfo.value) return
|
||||
|
||||
router.push({
|
||||
path: '/audit/integrations',
|
||||
query: {
|
||||
resource_type: cardInfo.value.asset_type,
|
||||
resource_key: cardInfo.value.identifier
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* IoT卡操作 - 启用卡
|
||||
*/
|
||||
@@ -814,6 +805,15 @@
|
||||
handleSearch({ identifier })
|
||||
}
|
||||
}
|
||||
const handleSpeedTierConfirm = async (data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }) => {
|
||||
if (!cardInfo.value?.iccid) return
|
||||
const response = await CardService.setSpeedTier(cardInfo.value.iccid, data.code)
|
||||
if (response.code === 0) {
|
||||
ElMessage.success(`限速设置成功:${response.data.speed_tier_name}`)
|
||||
speedLimitDialogVisible.value = false
|
||||
await handleRefresh()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -2299,7 +2299,8 @@
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('批量修改实名顺序成功')
|
||||
const successCount = res.data?.success_count ?? assetIds.length
|
||||
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
|
||||
batchRealnamePolicyDialogVisible.value = false
|
||||
selectedDevices.value = []
|
||||
await getTableData()
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
formatter: (_, data) =>
|
||||
data.flow_type_name || (flowType === 'direct' ? '直接换货' : '物流换货')
|
||||
},
|
||||
{
|
||||
label: '提交人',
|
||||
formatter: (value) => value || '--',
|
||||
prop: 'submitter_name'
|
||||
},
|
||||
{
|
||||
label: '换货原因',
|
||||
prop: 'exchange_reason',
|
||||
|
||||
@@ -380,7 +380,13 @@
|
||||
import { h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ExchangeService, CardService, DeviceService } from '@/api/modules'
|
||||
import type { ExchangeResponse } from '@/api/modules/exchange'
|
||||
import type {
|
||||
CreateExchangeRequest,
|
||||
ExchangeAssetType,
|
||||
ExchangeFlowType,
|
||||
ExchangeQueryParams,
|
||||
ExchangeResponse
|
||||
} from '@/api/modules/exchange'
|
||||
import type { StandaloneIotCard, Device } from '@/types/api'
|
||||
import { ElMessage, ElTag, ElButton, ElMessageBox, ElSwitch } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
@@ -422,7 +428,7 @@
|
||||
]
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
const searchForm = reactive<ExchangeQueryParams & { created_at_range: string[] }>({
|
||||
status: undefined,
|
||||
flow_type: undefined, // 流程类型筛选
|
||||
old_asset_keyword: '',
|
||||
@@ -433,7 +439,16 @@
|
||||
})
|
||||
|
||||
// 创建换货单表单
|
||||
const createForm = reactive({
|
||||
const createForm = reactive<{
|
||||
exchange_reason: string
|
||||
exchange_reason_other: string
|
||||
old_asset_type: ExchangeAssetType | ''
|
||||
old_identifier: string
|
||||
flow_type: ExchangeFlowType
|
||||
new_identifier: string
|
||||
migrate_data: boolean
|
||||
remark: string
|
||||
}>({
|
||||
exchange_reason: '',
|
||||
exchange_reason_other: '',
|
||||
old_asset_type: '',
|
||||
@@ -819,7 +834,7 @@
|
||||
loading.value = true
|
||||
try {
|
||||
// 过滤掉空值参数
|
||||
const params: any = {
|
||||
const params: ExchangeQueryParams = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size
|
||||
}
|
||||
@@ -1328,9 +1343,9 @@
|
||||
? createForm.exchange_reason_other.trim()
|
||||
: createForm.exchange_reason
|
||||
|
||||
const requestData: any = {
|
||||
const requestData: CreateExchangeRequest = {
|
||||
exchange_reason: exchangeReason,
|
||||
old_asset_type: createForm.old_asset_type,
|
||||
old_asset_type: createForm.old_asset_type as ExchangeAssetType,
|
||||
old_identifier: createForm.old_identifier,
|
||||
flow_type: createForm.flow_type,
|
||||
remark: createForm.remark || undefined
|
||||
@@ -1351,7 +1366,7 @@
|
||||
}
|
||||
|
||||
ElMessage.error(res.msg || '换货单创建失败')
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('创建换货单失败:', error)
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
|
||||
@@ -169,16 +169,16 @@
|
||||
{ label: '临期等级', prop: 'expiry_level_name' }
|
||||
]
|
||||
|
||||
const getExpiryClass = (days: number) => {
|
||||
if (days <= 3) return 'expiry-critical'
|
||||
if (days <= 7) return 'expiry-warning'
|
||||
if (days <= 15) return 'expiry-notice'
|
||||
const getExpiryClass = (level?: string | null) => {
|
||||
if (level === 'critical' || level === '0_3') return 'expiry-critical'
|
||||
if (level === 'warning' || level === '4_7') return 'expiry-warning'
|
||||
if (level === 'notice' || level === '8_15') return 'expiry-notice'
|
||||
return ''
|
||||
}
|
||||
|
||||
const getExpiryTagType = (days: number) => {
|
||||
if (days <= 3) return 'danger'
|
||||
if (days <= 7) return 'warning'
|
||||
const getExpiryTagType = (level?: string | null) => {
|
||||
if (level === 'critical' || level === '0_3') return 'danger'
|
||||
if (level === 'warning' || level === '4_7') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
formatter: (row: ExpiringAssetItem) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: getExpiryClass(row.days_until_final_expiry) },
|
||||
{ class: getExpiryClass(row.expiry_level) },
|
||||
formatDateTime(row.estimated_final_expires_at)
|
||||
)
|
||||
},
|
||||
@@ -256,7 +256,7 @@
|
||||
formatter: (row: ExpiringAssetItem) =>
|
||||
h(
|
||||
ElTag,
|
||||
{ type: getExpiryTagType(row.days_until_final_expiry), size: 'small' },
|
||||
{ type: getExpiryTagType(row.expiry_level), size: 'small' },
|
||||
() => `${row.days_until_final_expiry}天`
|
||||
)
|
||||
},
|
||||
@@ -2448,7 +2448,8 @@
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('批量修改实名顺序成功')
|
||||
const successCount = res.data?.success_count ?? assetIds.length
|
||||
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
|
||||
batchRealnamePolicyDialogVisible.value = false
|
||||
selectedCards.value = []
|
||||
await getTableData()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="device-task-page" id="table-full-screen">
|
||||
<div v-if="isPlatformAccount" class="device-task-page" id="table-full-screen">
|
||||
<!-- 搜索栏 -->
|
||||
<ArtSearchBar
|
||||
v-model:filter="searchForm"
|
||||
@@ -10,6 +10,16 @@
|
||||
@search="handleSearch"
|
||||
></ArtSearchBar>
|
||||
|
||||
<div v-if="pollingError || pollingForbidden" class="task-polling-error">
|
||||
<ElAlert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="pollingForbidden ? '当前账号无权查看该任务' : pollingError || '任务状态获取失败'"
|
||||
/>
|
||||
<ElButton size="small" @click="polling.retry">重试</ElButton>
|
||||
</div>
|
||||
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<!-- 表格头部 -->
|
||||
<ArtTableHeader
|
||||
@@ -19,10 +29,11 @@
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
v-if="isPlatformAccount && hasAuth(JULY_PERMISSIONS.deviceAllocation.page)"
|
||||
type="primary"
|
||||
:icon="Upload"
|
||||
@click="importDialogVisible = true"
|
||||
v-permission="'device_task:bulk_import'"
|
||||
v-permission="JULY_PERMISSIONS.deviceAllocation.page"
|
||||
>
|
||||
批量导入设备
|
||||
</ElButton>
|
||||
@@ -57,8 +68,8 @@
|
||||
<template #title>
|
||||
<div style="line-height: 1.8">
|
||||
<p><strong>导入说明:</strong></p>
|
||||
<p>1. 请先下载 Excel 模板文件,按照模板格式填写设备信息</p>
|
||||
<p>2. 仅支持 Excel 格式(.xlsx),单次最多导入 1000 条</p>
|
||||
<p>1. 设备分配使用单列 UTF-8 CSV,导入设备仍使用 Excel 模板</p>
|
||||
<p>2. CSV 单次最多 1000 条,文件不超过 10MB</p>
|
||||
<p>3. 列格式请设置为文本格式,避免长数字被转为科学计数法</p>
|
||||
<p>4. <strong>重要:列顺序固定,不可调整。</strong>系统按位置读取,不识别列名</p>
|
||||
<p style="color: var(--el-color-primary)">5. 必填列:虚拟号(第1列)</p>
|
||||
@@ -77,6 +88,22 @@
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="任务类型">
|
||||
<ElSelect v-model="importForm.operation_type" style="width: 100%">
|
||||
<ElOption label="导入设备" value="import" />
|
||||
<ElOption label="分配目标代理" value="assign_shop" />
|
||||
<ElOption label="设置套餐系列" value="assign_series" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol v-if="importForm.operation_type !== 'import'" :span="12">
|
||||
<ElFormItem label="目标 ID">
|
||||
<ElInput v-model="importForm.target_id" placeholder="代理店铺或套餐系列 ID" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="批次号" prop="batch_no">
|
||||
@@ -114,12 +141,12 @@
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
:limit="1"
|
||||
accept=".xlsx"
|
||||
accept=".xlsx,.csv"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||
<div class="el-upload__text">将 Excel 文件拖到此处,或<em>点击选择</em></div>
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击选择</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">只能上传 .xlsx 格式的 Excel 文件,且不超过 300MB</div>
|
||||
<div class="el-upload__tip">导入设备支持 .xlsx;批量分配支持单列 .csv</div>
|
||||
</template>
|
||||
</ElUpload>
|
||||
|
||||
@@ -140,8 +167,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { DeviceService } from '@/api/modules'
|
||||
import { ElMessage, ElTag } from 'element-plus'
|
||||
import { Download, UploadFilled, Upload } from '@element-plus/icons-vue'
|
||||
@@ -149,16 +176,29 @@
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { StorageService } from '@/api/modules/storage'
|
||||
import type { DeviceImportTask, DeviceImportTaskStatus, RealnamePolicy } from '@/types/api/device'
|
||||
import type {
|
||||
DeviceImportTask,
|
||||
DeviceImportTaskDetail,
|
||||
DeviceImportTaskQueryParams,
|
||||
DeviceImportTaskStatus,
|
||||
RealnamePolicy
|
||||
} from '@/types/api/device'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { generatePackageCode } from '@/utils/codeGenerator'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
|
||||
defineOptions({ name: 'DeviceTask' })
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { hasAuth } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
|
||||
|
||||
const loading = ref(false)
|
||||
const tableRef = ref()
|
||||
@@ -168,14 +208,16 @@
|
||||
const importDialogVisible = ref(false)
|
||||
const importForm = reactive({
|
||||
batch_no: '',
|
||||
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order'
|
||||
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order',
|
||||
operation_type: 'import' as 'import' | 'assign_shop' | 'assign_series',
|
||||
target_id: ''
|
||||
})
|
||||
|
||||
// 搜索表单初始值
|
||||
const initialSearchState = {
|
||||
status: undefined,
|
||||
batch_no: '',
|
||||
dateRange: undefined as any,
|
||||
dateRange: undefined as string[] | undefined,
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
}
|
||||
@@ -247,6 +289,23 @@
|
||||
|
||||
const taskList = ref<DeviceImportTask[]>([])
|
||||
|
||||
const polling = useAsyncTaskPolling<DeviceImportTaskDetail>({
|
||||
storageKey: 'device-import-active-task',
|
||||
autoRestore: false,
|
||||
fetchTask: async (taskId) => {
|
||||
const res = await DeviceService.getImportTaskDetail(taskId)
|
||||
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取设备任务失败')
|
||||
return res.data
|
||||
},
|
||||
isForbidden: (error) => {
|
||||
const response = (error as { response?: { status?: number; data?: { code?: number } } })
|
||||
?.response
|
||||
return response?.status === 403 || response?.data?.code === 403
|
||||
}
|
||||
})
|
||||
const pollingError = polling.error
|
||||
const pollingForbidden = polling.forbidden
|
||||
|
||||
// 获取状态标签类型
|
||||
const getStatusType = (status: DeviceImportTaskStatus) => {
|
||||
switch (status) {
|
||||
@@ -265,6 +324,7 @@
|
||||
|
||||
// 查看详情
|
||||
const viewDetail = (row: DeviceImportTask) => {
|
||||
if (!isPlatformAccount.value) return
|
||||
router.push({
|
||||
path: RoutesAlias.TaskDetail,
|
||||
query: {
|
||||
@@ -276,7 +336,7 @@
|
||||
|
||||
// 处理名称点击
|
||||
const handleNameClick = (row: DeviceImportTask) => {
|
||||
if (hasAuth('device_task:view_detail')) {
|
||||
if (isPlatformAccount.value && hasAuth('device_task:view_detail')) {
|
||||
viewDetail(row)
|
||||
} else {
|
||||
ElMessage.warning('您没有查看详情的权限')
|
||||
@@ -385,15 +445,31 @@
|
||||
}
|
||||
])
|
||||
|
||||
watch(polling.task, () => {
|
||||
if (isPlatformAccount.value) void getTableData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
getTableData()
|
||||
if (!isPlatformAccount.value) return
|
||||
void getTableData()
|
||||
const routeTaskId = Number(route.query.task_id)
|
||||
if (routeTaskId) {
|
||||
void polling.start(routeTaskId)
|
||||
} else if (polling.taskId.value) {
|
||||
void polling.retry()
|
||||
}
|
||||
})
|
||||
|
||||
// 获取设备任务列表
|
||||
const getTableData = async () => {
|
||||
if (!isPlatformAccount.value) {
|
||||
taskList.value = []
|
||||
pagination.total = 0
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
const params: DeviceImportTaskQueryParams = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
status: searchForm.status,
|
||||
@@ -406,13 +482,6 @@
|
||||
params.end_time = searchForm.dateRange[1]
|
||||
}
|
||||
|
||||
// 清理空值
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (params[key] === '' || params[key] === undefined) {
|
||||
delete params[key]
|
||||
}
|
||||
})
|
||||
|
||||
const res = await DeviceService.getImportTasks(params)
|
||||
if (res.code === 0) {
|
||||
const taskItems = (res.data as typeof res.data & { items?: DeviceImportTask[] | null })
|
||||
@@ -422,6 +491,7 @@
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ElMessage.error(normalizeApiError(error).message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -473,8 +543,9 @@
|
||||
}
|
||||
|
||||
// 文件选择变化
|
||||
const handleFileChange = (uploadFile: any) => {
|
||||
const maxSize = 300 * 1024 * 1024
|
||||
const handleFileChange = async (uploadFile: any) => {
|
||||
const isCsvAllocation = importForm.operation_type !== 'import'
|
||||
const maxSize = (isCsvAllocation ? 10 : 300) * 1024 * 1024
|
||||
if (uploadFile.raw && uploadFile.raw.size > maxSize) {
|
||||
ElMessage.error('文件大小不能超过 300MB')
|
||||
uploadRef.value?.clearFiles()
|
||||
@@ -482,13 +553,23 @@
|
||||
return
|
||||
}
|
||||
|
||||
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.xlsx')) {
|
||||
ElMessage.error('只能上传 .xlsx 格式的 Excel 文件')
|
||||
if (uploadFile.raw && (isCsvAllocation ? !uploadFile.raw.name.endsWith('.csv') : !uploadFile.raw.name.endsWith('.xlsx'))) {
|
||||
ElMessage.error(isCsvAllocation ? '批量分配只能上传 .csv 文件' : '设备导入只能上传 .xlsx 文件')
|
||||
uploadRef.value?.clearFiles()
|
||||
fileList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (isCsvAllocation && uploadFile.raw) {
|
||||
const rows = (await uploadFile.raw.text()).replace(/^\uFEFF/, '').split(/\r?\n/).filter(Boolean)
|
||||
if (rows.length > 1001 || rows.some((row: string) => row.includes(','))) {
|
||||
ElMessage.error('设备分配 CSV 必须是单列且最多 1000 行数据')
|
||||
uploadRef.value?.clearFiles()
|
||||
fileList.value = []
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fileList.value = uploadFile.raw ? [uploadFile.raw] : []
|
||||
}
|
||||
|
||||
@@ -510,12 +591,19 @@
|
||||
clearFiles()
|
||||
importForm.batch_no = ''
|
||||
importForm.realname_policy = ''
|
||||
importForm.operation_type = 'import'
|
||||
importForm.target_id = ''
|
||||
importDialogVisible.value = false
|
||||
}
|
||||
// 提交上传
|
||||
const submitUpload = async () => {
|
||||
if (!isPlatformAccount.value || !hasAuth(JULY_PERMISSIONS.deviceAllocation.page)) return
|
||||
if (!fileList.value.length) {
|
||||
ElMessage.warning('请先选择 Excel 文件')
|
||||
ElMessage.warning('请先选择文件')
|
||||
return
|
||||
}
|
||||
if (importForm.operation_type !== 'import' && !Number(importForm.target_id)) {
|
||||
ElMessage.warning('请输入有效的目标 ID')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -524,10 +612,12 @@
|
||||
|
||||
try {
|
||||
ElMessage.info('正在准备上传...')
|
||||
const isAllocation = importForm.operation_type !== 'import'
|
||||
const contentType = isAllocation ? 'text/csv' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
const uploadUrlRes = await StorageService.getUploadUrl({
|
||||
file_name: file.name,
|
||||
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
purpose: 'iot_import'
|
||||
content_type: contentType,
|
||||
purpose: isAllocation ? 'device_batch_allocation' : 'iot_import'
|
||||
})
|
||||
|
||||
if (uploadUrlRes.code !== 0) {
|
||||
@@ -538,14 +628,16 @@
|
||||
const { upload_url, file_key } = uploadUrlRes.data
|
||||
|
||||
ElMessage.info('正在上传文件...')
|
||||
await StorageService.uploadFile(
|
||||
upload_url,
|
||||
file,
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
await StorageService.uploadFile(upload_url, file, contentType)
|
||||
|
||||
ElMessage.info('正在创建导入任务...')
|
||||
const importRes = await DeviceService.importDevices({
|
||||
ElMessage.info(isAllocation ? '正在创建分配任务...' : '正在创建导入任务...')
|
||||
const importRes = isAllocation
|
||||
? await DeviceService.createAllocationTask({
|
||||
file_key,
|
||||
operation_type: importForm.operation_type as 'assign_shop' | 'assign_series',
|
||||
target_id: Number(importForm.target_id)
|
||||
})
|
||||
: await DeviceService.importDevices({
|
||||
file_key,
|
||||
batch_no: importForm.batch_no || undefined,
|
||||
realname_policy: (importForm.realname_policy || undefined) as RealnamePolicy | undefined
|
||||
@@ -557,8 +649,11 @@
|
||||
}
|
||||
|
||||
const taskNo = importRes.data.task_no
|
||||
const taskId = importRes.data.task_id
|
||||
|
||||
handleCancelImport()
|
||||
await router.replace({ path: route.path, query: { task_id: String(taskId) } })
|
||||
await polling.start(taskId)
|
||||
await getTableData()
|
||||
|
||||
ElMessage.success({
|
||||
@@ -568,7 +663,7 @@
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('设备导入失败:', error)
|
||||
console.log(error.message || '设备导入失败')
|
||||
ElMessage.error(normalizeApiError(error).message || error.message || '设备导入失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
@@ -576,6 +671,7 @@
|
||||
|
||||
// 从行数据下载失败数据
|
||||
const downloadFailDataByRow = async (row: DeviceImportTask) => {
|
||||
if (!isPlatformAccount.value) return
|
||||
try {
|
||||
const res = await DeviceService.getImportTaskDetail(row.id)
|
||||
if (res.code === 0 && res.data) {
|
||||
@@ -622,6 +718,7 @@
|
||||
}
|
||||
|
||||
const downloadTaskFile = async (row: DeviceImportTask) => {
|
||||
if (!isPlatformAccount.value) return
|
||||
const fileKey = row.file_name?.trim()
|
||||
if (!fileKey) {
|
||||
ElMessage.warning('当前任务没有可下载的原始文件')
|
||||
@@ -639,6 +736,7 @@
|
||||
|
||||
// 获取操作按钮
|
||||
const getActions = (row: DeviceImportTask) => {
|
||||
if (!isPlatformAccount.value) return []
|
||||
const actions: any[] = []
|
||||
const showDownloadFileAction = false
|
||||
|
||||
@@ -664,6 +762,13 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.device-task-page {
|
||||
.task-polling-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-icon--upload) {
|
||||
margin-bottom: 16px;
|
||||
font-size: 67px;
|
||||
|
||||
@@ -59,11 +59,14 @@
|
||||
import type { DeviceImportTaskDetail } from '@/types/api/device'
|
||||
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
||||
import DetailPage from '@/components/common/DetailPage.vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
defineOptions({ name: 'TaskDetail' })
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
|
||||
|
||||
type TaskType = 'card' | 'device'
|
||||
type TaskDetail = IotCardImportTaskDetail | DeviceImportTaskDetail
|
||||
@@ -244,6 +247,12 @@
|
||||
taskType.value = queryTaskType
|
||||
}
|
||||
|
||||
if (taskType.value === 'device' && !isPlatformAccount.value) {
|
||||
ElMessage.error('当前账号无权查看设备任务详情')
|
||||
goBack()
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (taskType.value === 'device') {
|
||||
|
||||
@@ -507,6 +507,7 @@
|
||||
CommissionSourceMap
|
||||
} from '@/config/constants/commission'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
|
||||
defineOptions({ name: 'AgentCommission' })
|
||||
|
||||
@@ -1021,9 +1022,18 @@
|
||||
}
|
||||
|
||||
const handleCreditConflict = async () => {
|
||||
ElMessage.warning('资金概况已被更新,已刷新最新数据,请重新调整')
|
||||
creditDialogVisible.value = false
|
||||
const shopId = currentCreditShop.value?.shop_id
|
||||
await getTableData()
|
||||
|
||||
if (shopId) {
|
||||
const latest = summaryList.value.find((item) => item.shop_id === shopId)
|
||||
if (latest) {
|
||||
// 仅刷新后端版本和当前余额,保留用户刚填写的表单值,方便确认后重试。
|
||||
currentCreditShop.value = latest
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.warning('资金概况已被其他操作更新,已刷新最新版本;请确认后重新提交')
|
||||
}
|
||||
|
||||
const handleCreditSubmit = async () => {
|
||||
@@ -1058,6 +1068,7 @@
|
||||
await handleCreditConflict()
|
||||
} else {
|
||||
console.error('实际信用额度调整失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message)
|
||||
}
|
||||
} finally {
|
||||
creditSubmitting.value = false
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
2: 'success', // 已支付
|
||||
3: 'success', // 已完成
|
||||
4: 'info', // 已关闭
|
||||
5: 'danger' // 已退款
|
||||
5: 'danger', // 已退款
|
||||
6: 'danger' // 已驳回
|
||||
}
|
||||
return statusMap[status] || 'info'
|
||||
}
|
||||
@@ -81,7 +82,8 @@
|
||||
2: '已支付',
|
||||
3: '已完成',
|
||||
4: '已关闭',
|
||||
5: '已退款'
|
||||
5: '已退款',
|
||||
6: '已驳回'
|
||||
}
|
||||
return statusMap[status] || '-'
|
||||
}
|
||||
|
||||
@@ -464,7 +464,8 @@
|
||||
2: 'success', // 已支付
|
||||
3: 'success', // 已完成
|
||||
4: 'info', // 已关闭
|
||||
5: 'danger' // 已退款
|
||||
5: 'danger', // 已退款
|
||||
6: 'danger' // 已驳回
|
||||
}
|
||||
return statusMap[status] || 'info'
|
||||
}
|
||||
@@ -477,7 +478,8 @@
|
||||
2: '已支付',
|
||||
3: '已完成',
|
||||
4: '已关闭',
|
||||
5: '已退款'
|
||||
5: '已退款',
|
||||
6: '已驳回'
|
||||
}
|
||||
return statusMap[status] || '-'
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
<ElButton
|
||||
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.template)"
|
||||
tag="a"
|
||||
href="/templates/bulk-purchase-template.xlsx"
|
||||
download="批量订购模板.xlsx"
|
||||
href="/templates/bulk-purchase-template.csv"
|
||||
download="批量订购套餐模板.csv"
|
||||
>
|
||||
下载模板
|
||||
</ElButton>
|
||||
@@ -20,25 +20,8 @@
|
||||
</template>
|
||||
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px" class="create-form">
|
||||
<ElFormItem label="代理商" prop="shop_id">
|
||||
<ElSelect
|
||||
v-model="form.shop_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
clearable
|
||||
placeholder="请选择代理商"
|
||||
:remote-method="searchShops"
|
||||
:loading="shopLoading"
|
||||
style="width: 100%"
|
||||
>
|
||||
<ElOption
|
||||
v-for="shop in shopOptions"
|
||||
:key="shop.id"
|
||||
:label="`${shop.shop_name} (${shop.shop_code})`"
|
||||
:value="shop.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElFormItem label="套餐 ID" prop="package_id">
|
||||
<ElInputNumber v-model="form.package_id" :min="1" controls-position="right" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="支付方式" prop="payment_method">
|
||||
@@ -54,8 +37,12 @@
|
||||
v-model="orderFileKeys"
|
||||
voucher-name="订单文件"
|
||||
:max-count="1"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
tip="支持 .xlsx、.xls 或 .csv 文件"
|
||||
purpose="batch_purchase"
|
||||
:max-size-mb="10"
|
||||
single-column-csv
|
||||
:max-csv-rows="1000"
|
||||
accept=".csv"
|
||||
tip="仅支持 UTF-8 单列 CSV,最多 1000 行,最大 10MB"
|
||||
@uploading-change="orderFileUploading = $event"
|
||||
@change="formRef?.validateField('orderFile')"
|
||||
/>
|
||||
@@ -210,6 +197,7 @@
|
||||
ElCard,
|
||||
ElDescriptions,
|
||||
ElDescriptionsItem,
|
||||
ElInputNumber,
|
||||
ElForm,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
@@ -223,12 +211,11 @@
|
||||
} from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
import { BulkPurchaseService, ShopService } from '@/api/modules'
|
||||
import { BulkPurchaseService } from '@/api/modules'
|
||||
import type {
|
||||
BulkPurchaseItem,
|
||||
BulkPurchasePaymentMethod,
|
||||
BulkPurchaseTask,
|
||||
ShopResponse
|
||||
BulkPurchaseTask
|
||||
} from '@/types/api'
|
||||
import { BulkPurchaseTaskStatus } from '@/types/api'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
@@ -244,8 +231,6 @@
|
||||
const formRef = ref<FormInstance>()
|
||||
const orderUploadRef = ref<InstanceType<typeof VoucherUpload>>()
|
||||
const voucherUploadRef = ref<InstanceType<typeof VoucherUpload>>()
|
||||
const shopOptions = ref<ShopResponse[]>([])
|
||||
const shopLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const orderFileKeys = ref<string[]>([])
|
||||
const orderFileUploading = ref(false)
|
||||
@@ -260,15 +245,15 @@
|
||||
const requestId = ref('')
|
||||
|
||||
const form = reactive<{
|
||||
shop_id?: number
|
||||
package_id?: number
|
||||
payment_method: BulkPurchasePaymentMethod
|
||||
}>({
|
||||
shop_id: undefined,
|
||||
package_id: undefined,
|
||||
payment_method: 'wallet'
|
||||
})
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
shop_id: [{ required: true, message: '请选择代理商', trigger: 'change' }],
|
||||
package_id: [{ required: true, message: '请输入套餐 ID', trigger: 'change' }],
|
||||
payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }],
|
||||
orderFile: [
|
||||
{
|
||||
@@ -310,20 +295,6 @@
|
||||
return requestId.value
|
||||
}
|
||||
|
||||
const searchShops = async (query: string) => {
|
||||
shopLoading.value = true
|
||||
try {
|
||||
const res = await ShopService.getShops({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
shop_name: query || undefined
|
||||
})
|
||||
if (res.code === 0) shopOptions.value = res.data.items || []
|
||||
} finally {
|
||||
shopLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaymentMethodChange = (value: string | number | boolean | undefined) => {
|
||||
if (value === 'wallet') {
|
||||
voucherFileKeys.value = []
|
||||
@@ -341,17 +312,15 @@
|
||||
)
|
||||
return
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid || !form.shop_id || orderFileKeys.value.length === 0) return
|
||||
if (!valid || !form.package_id || orderFileKeys.value.length === 0) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = new FormData()
|
||||
data.append('shop_id', String(form.shop_id))
|
||||
data.append('payment_method', form.payment_method)
|
||||
data.append('file', orderFileKeys.value[0])
|
||||
data.append('request_id', getRequestId())
|
||||
if (form.payment_method === 'offline' && voucherFileKeys.value.length > 0) {
|
||||
data.append('voucher_file', voucherFileKeys.value[0])
|
||||
const data = {
|
||||
file_key: orderFileKeys.value[0],
|
||||
package_id: form.package_id,
|
||||
payment_method: form.payment_method,
|
||||
...(form.payment_method === 'offline' ? { voucher_keys: voucherFileKeys.value } : {})
|
||||
}
|
||||
|
||||
const res = await BulkPurchaseService.createTask(data)
|
||||
@@ -376,26 +345,9 @@
|
||||
}
|
||||
|
||||
const loadItems = async () => {
|
||||
const taskId = taskDetail.value?.task_id
|
||||
const taskId = taskDetail.value?.id || taskDetail.value?.task_id
|
||||
if (!taskId || !hasAuth(BULK_PURCHASE_PERMISSIONS.items)) return
|
||||
itemsLoading.value = true
|
||||
try {
|
||||
const res = await BulkPurchaseService.getItems(taskId, {
|
||||
page: itemsPage.value,
|
||||
size: itemsSize.value,
|
||||
status: itemStatus.value
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
items.value = res.data.items || []
|
||||
itemsTotal.value = res.data.total || 0
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取批量订购明细失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '获取批量订购明细失败')
|
||||
} finally {
|
||||
itemsLoading.value = false
|
||||
}
|
||||
items.value = taskDetail.value?.items || []
|
||||
}
|
||||
|
||||
const reloadItems = () => {
|
||||
@@ -441,7 +393,6 @@
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (hasAuth(BULK_PURCHASE_PERMISSIONS.create)) void searchShops('')
|
||||
const routeTaskId = Number(route.query.task_id)
|
||||
if (routeTaskId && routeTaskId !== polling.taskId.value) void polling.start(routeTaskId)
|
||||
})
|
||||
|
||||
@@ -104,6 +104,22 @@
|
||||
return type === 'single_card' ? '单卡购买' : '设备购买'
|
||||
}
|
||||
|
||||
const getAssetTypeText = (type?: string): string => {
|
||||
if (type === 'card') return 'IoT卡'
|
||||
if (type === 'device') return '设备'
|
||||
return '-'
|
||||
}
|
||||
|
||||
const getPaymentMethodText = (method?: string): string => {
|
||||
const methodMap: Record<string, string> = {
|
||||
wallet: '钱包',
|
||||
wechat: '微信',
|
||||
alipay: '支付宝',
|
||||
offline: '线下'
|
||||
}
|
||||
return method ? methodMap[method] || method : '-'
|
||||
}
|
||||
|
||||
// 获取买家类型文本
|
||||
const getBuyerTypeText = (type: string): string => {
|
||||
return type === 'personal' ? '个人客户' : '代理商'
|
||||
@@ -174,10 +190,19 @@
|
||||
label: '订单类型',
|
||||
formatter: (_: any, data: any) => getOrderTypeText(data.order_type)
|
||||
},
|
||||
{
|
||||
label: '资产类型',
|
||||
formatter: (_, data) => getAssetTypeText(data.asset_type)
|
||||
},
|
||||
{
|
||||
label: '支付状态',
|
||||
formatter: (_: any, data: any) => data.payment_status_text || '-'
|
||||
},
|
||||
{
|
||||
label: '支付方式',
|
||||
prop: 'payment_method',
|
||||
formatter: (value) => getPaymentMethodText(value)
|
||||
},
|
||||
{
|
||||
label: '订单金额',
|
||||
prop: 'total_amount',
|
||||
@@ -253,7 +278,12 @@
|
||||
formatter: (_, data) => (data.is_purchased_by_parent ? '是' : '否')
|
||||
},
|
||||
{
|
||||
label: 'ICCID/VirtualNo',
|
||||
label: '是否代购',
|
||||
formatter: (_, data) =>
|
||||
data.is_purchase_on_behalf === undefined ? '-' : data.is_purchase_on_behalf ? '是' : '否'
|
||||
},
|
||||
{
|
||||
label: '资产标识符',
|
||||
prop: 'asset_identifier',
|
||||
formatter: (value) => value || '-'
|
||||
},
|
||||
|
||||
@@ -413,7 +413,7 @@
|
||||
options: [
|
||||
{ label: '钱包支付', value: 'wallet' },
|
||||
{ label: '微信支付', value: 'wechat' },
|
||||
// { label: '支付宝支付', value: 'alipay' },
|
||||
{ label: '支付宝支付', value: 'alipay' },
|
||||
{ label: '线下支付', value: 'offline' }
|
||||
],
|
||||
config: {
|
||||
@@ -468,9 +468,9 @@
|
||||
placeholder: '请选择订单渠道',
|
||||
options: [
|
||||
{ label: '自己购买', value: 'self_purchase' },
|
||||
// { label: '上级代理购买', value: 'purchased_by_parent' },
|
||||
{ label: '平台代购', value: 'purchased_by_platform' }
|
||||
// { label: '给下级购买', value: 'purchase_for_subordinate' }
|
||||
{ label: '上级代理购买', value: 'purchased_by_parent' },
|
||||
{ label: '平台代购', value: 'purchased_by_platform' },
|
||||
{ label: '给下级购买', value: 'purchase_for_subordinate' }
|
||||
],
|
||||
config: {
|
||||
clearable: true
|
||||
@@ -504,17 +504,22 @@
|
||||
{ label: '买家手机号', prop: 'buyer_phone' },
|
||||
{ label: '买家昵称', prop: 'buyer_nickname' },
|
||||
{ label: '订单类型', prop: 'order_type' },
|
||||
{ label: '资产类型', prop: 'asset_type' },
|
||||
{ label: '买家类型', prop: 'buyer_type' },
|
||||
{ label: '资产标识符', prop: 'asset_identifier' },
|
||||
{ label: '订单渠道', prop: 'purchase_role' },
|
||||
{ label: '购买备注', prop: 'purchase_remark' },
|
||||
{ label: '是否代购', prop: 'is_purchase_on_behalf' },
|
||||
{ label: '是否上级代购', prop: 'is_purchased_by_parent' },
|
||||
{ label: '下单时间', prop: 'created_at' },
|
||||
{ label: '操作者', prop: 'operator_name' },
|
||||
{ label: '销售店铺', prop: 'seller_shop_name' },
|
||||
{ label: '支付状态', prop: 'payment_status' },
|
||||
{ label: '佣金流程状态', prop: 'commission_status_name' },
|
||||
{ label: '佣金业务结果', prop: 'commission_result_name' },
|
||||
{ label: '订单金额', prop: 'total_amount' }
|
||||
{ label: '订单金额', prop: 'total_amount' },
|
||||
{ label: '是否过期', prop: 'is_expired' },
|
||||
{ label: '超时时间', prop: 'expires_at' }
|
||||
]
|
||||
|
||||
// 只有非代理账号和非企业账号才能看到实付金额选项
|
||||
@@ -854,6 +859,13 @@
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'asset_type',
|
||||
label: '资产类型',
|
||||
width: 110,
|
||||
formatter: (row: Order) =>
|
||||
row.asset_type === 'card' ? 'IoT卡' : row.asset_type === 'device' ? '设备' : '-'
|
||||
},
|
||||
{
|
||||
prop: 'buyer_type',
|
||||
label: '买家类型',
|
||||
@@ -876,6 +888,18 @@
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'is_expired',
|
||||
label: '是否过期',
|
||||
width: 100,
|
||||
formatter: (row: Order) => (row.is_expired === undefined ? '-' : row.is_expired ? '是' : '否')
|
||||
},
|
||||
{
|
||||
prop: 'expires_at',
|
||||
label: '超时时间',
|
||||
width: 180,
|
||||
formatter: (row: Order) => (row.expires_at ? formatDateTime(row.expires_at) : '-')
|
||||
},
|
||||
{
|
||||
prop: 'asset_identifier',
|
||||
label: '资产标识符',
|
||||
@@ -909,6 +933,20 @@
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: Order) => row.purchase_remark || '-'
|
||||
},
|
||||
{
|
||||
prop: 'is_purchase_on_behalf',
|
||||
label: '是否代购',
|
||||
width: 100,
|
||||
formatter: (row: Order) =>
|
||||
row.is_purchase_on_behalf === undefined ? '-' : row.is_purchase_on_behalf ? '是' : '否'
|
||||
},
|
||||
{
|
||||
prop: 'is_purchased_by_parent',
|
||||
label: '是否上级代购',
|
||||
width: 110,
|
||||
formatter: (row: Order) =>
|
||||
row.is_purchased_by_parent === undefined ? '-' : row.is_purchased_by_parent ? '是' : '否'
|
||||
},
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '下单时间',
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
import { PackageManageService } from '@/api/modules'
|
||||
import type { PackageResponse } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { getStatusLabel, getShelfStatusText } from '@/config/constants'
|
||||
import { getShelfStatusText } from '@/config/constants'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
defineOptions({ name: 'PackageDetail' })
|
||||
@@ -103,7 +103,16 @@
|
||||
{
|
||||
label: '价格配置状态',
|
||||
formatter: (_: unknown, data: PackageResponse) => {
|
||||
return data.price_config_status_name || '-'
|
||||
const statusMap: Record<number, string> = {
|
||||
0: '未配置',
|
||||
1: '赠送 0 价',
|
||||
2: '已配置非 0'
|
||||
}
|
||||
return (
|
||||
data.price_config_status_name ||
|
||||
statusMap[data.price_config_status ?? -1] ||
|
||||
'-'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -179,7 +188,9 @@
|
||||
{
|
||||
label: '状态',
|
||||
formatter: (_: unknown, data: PackageResponse) => {
|
||||
return getStatusLabel(data.status ?? 0)
|
||||
if (data.status === 1) return '启用'
|
||||
if (data.status === 0 || data.status === 2) return '禁用'
|
||||
return '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -224,6 +235,13 @@
|
||||
}
|
||||
return `${virtualDataMb} MB`
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '虚流量比例',
|
||||
formatter: (_: unknown, data: PackageResponse) =>
|
||||
data.virtual_ratio === null || data.virtual_ratio === undefined
|
||||
? '-'
|
||||
: Number(data.virtual_ratio).toFixed(2)
|
||||
}
|
||||
])
|
||||
]
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<ElButton type="primary" @click="showDialog('add')" v-permission="'package:add'"
|
||||
>新增套餐</ElButton
|
||||
>
|
||||
<ElButton v-if="hasAuth('package:export')" @click="exportDialogVisible = true">
|
||||
导出
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
@@ -45,6 +48,14 @@
|
||||
</template>
|
||||
</ArtTable>
|
||||
|
||||
<ExportTaskCreateDialog
|
||||
v-model="exportDialogVisible"
|
||||
scene="package"
|
||||
:query="exportQuery"
|
||||
confirm-permission="package:export"
|
||||
title="导出套餐"
|
||||
/>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
@@ -240,11 +251,12 @@
|
||||
>
|
||||
<div>
|
||||
缩减比例:{{ realDataGb }}×(1-{{ virtualRatioPercent }}%) =
|
||||
{{ calculatedVirtualDataGb }}GB
|
||||
{{ formatTwoDecimals(calculatedVirtualDataGb) }}GB
|
||||
</div>
|
||||
<div v-if="calculatedVirtualDataGb > 0">
|
||||
增长比例:{{ realDataGb }}GB/{{ calculatedVirtualDataGb }}GB =
|
||||
{{ calculatedGrowthRatio * 100 }}%
|
||||
增长比例:{{ realDataGb }}GB/{{
|
||||
formatTwoDecimals(calculatedVirtualDataGb)
|
||||
}}GB = {{ formatTwoDecimals(calculatedGrowthRatio * 100) }}%
|
||||
</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
@@ -407,10 +419,10 @@
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
import {
|
||||
CommonStatus,
|
||||
getStatusText,
|
||||
frontendStatusToApi,
|
||||
apiStatusToFrontend,
|
||||
PACKAGE_TYPE_OPTIONS,
|
||||
getPackageTypeLabel,
|
||||
@@ -434,6 +446,7 @@
|
||||
const shouldHideVirtualTrafficColumns = computed(() => [3, 4].includes(currentUserType.value))
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const exportDialogVisible = ref(false)
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const seriesLoading = ref(false)
|
||||
@@ -589,6 +602,7 @@
|
||||
]),
|
||||
{ label: '套餐周期类型', prop: 'calendar_type' },
|
||||
{ label: '有效期', prop: 'duration_months' },
|
||||
{ label: '套餐天数', prop: 'duration_days' },
|
||||
{ label: '总流量', prop: 'real_data_mb' },
|
||||
...(!shouldHideVirtualTrafficColumns.value
|
||||
? [
|
||||
@@ -607,8 +621,8 @@
|
||||
formatter: (row: any) => {
|
||||
const map: Record<string, string> = {
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
yearly: '每年',
|
||||
none: '不重置'
|
||||
}
|
||||
return map[row.data_reset_cycle] || row.data_reset_cycle || '-'
|
||||
@@ -632,8 +646,8 @@
|
||||
formatter: (row: PackageResponse) => row.expiry_base_override_name || '跟随套餐默认'
|
||||
},
|
||||
{ label: '最终生效条件', prop: 'effective_expiry_base_name' },
|
||||
...(canUpdatePackageShelfStatus ? [{ label: '上架状态', prop: 'shelf_status' }] : []),
|
||||
...(canUpdatePackageStatus ? [{ label: '状态', prop: 'status' }] : []),
|
||||
{ label: '上架状态', prop: 'shelf_status' },
|
||||
{ label: '状态', prop: 'status' },
|
||||
{ label: '创建时间', prop: 'created_at' },
|
||||
{ label: '更新时间', prop: 'updated_at' }
|
||||
]
|
||||
@@ -749,6 +763,8 @@
|
||||
return Number((realDataGb.value / calculatedVirtualDataGb.value).toFixed(2))
|
||||
})
|
||||
|
||||
const formatTwoDecimals = (value: number) => value.toFixed(2)
|
||||
|
||||
// GB 转 MB 处理
|
||||
const handleRealDataChange = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) {
|
||||
@@ -902,7 +918,17 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
: [
|
||||
{
|
||||
prop: 'shelf_status',
|
||||
label: '上架状态',
|
||||
width: 100,
|
||||
formatter: (row: PackageResponse) =>
|
||||
h(ElTag, { type: row.shelf_status === 1 ? 'success' : 'info', size: 'small' }, () =>
|
||||
getShelfStatusText(row.shelf_status ?? 0)
|
||||
)
|
||||
}
|
||||
]),
|
||||
...(canUpdatePackageStatus
|
||||
? [
|
||||
{
|
||||
@@ -924,7 +950,24 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
: [
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
width: 100,
|
||||
formatter: (row: PackageResponse) => {
|
||||
const frontendStatus = apiStatusToFrontend(row.status ?? 0)
|
||||
return h(
|
||||
ElTag,
|
||||
{
|
||||
type: frontendStatus === CommonStatus.ENABLED ? 'success' : 'danger',
|
||||
size: 'small'
|
||||
},
|
||||
() => getStatusText(frontendStatus)
|
||||
)
|
||||
}
|
||||
}
|
||||
]),
|
||||
{
|
||||
prop: 'real_data_mb',
|
||||
label: '总流量',
|
||||
@@ -959,28 +1002,36 @@
|
||||
prop: 'virtual_ratio',
|
||||
label: '虚流量比例',
|
||||
width: 120,
|
||||
formatter: (row: PackageResponse) => {
|
||||
// 如果启用虚流量且真流量大于0,计算虚量百分比
|
||||
const virtualData = row.virtual_data_mb ?? 0
|
||||
const realData = row.real_data_mb ?? 0
|
||||
if (row.enable_virtual_data && realData > 0 && virtualData > 0) {
|
||||
// 虚量百分比 = (1 - 虚流量/真流量) * 100%
|
||||
// 例如:真流量100G,虚流量70G,则虚量百分比 = (1 - 70/100) * 100% = 30%
|
||||
const ratio = (1 - virtualData / realData) * 100
|
||||
return `${ratio.toFixed(2)}%`
|
||||
}
|
||||
// 否则返回 0%
|
||||
return '0%'
|
||||
}
|
||||
formatter: (row: PackageResponse) =>
|
||||
row.virtual_ratio === null || row.virtual_ratio === undefined
|
||||
? '-'
|
||||
: formatTwoDecimals(Number(row.virtual_ratio))
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
prop: 'enable_virtual_data',
|
||||
label: '启用虚流量',
|
||||
width: 110,
|
||||
formatter: (row: PackageResponse) => (row.enable_virtual_data ? '是' : '否')
|
||||
},
|
||||
{
|
||||
prop: 'duration_months',
|
||||
label: '有效期',
|
||||
width: 100,
|
||||
formatter: (row: PackageResponse) => `${row.duration_months}月`
|
||||
},
|
||||
{
|
||||
prop: 'duration_days',
|
||||
label: '套餐天数',
|
||||
width: 100,
|
||||
formatter: (row: PackageResponse) =>
|
||||
row.calendar_type === 'by_day' &&
|
||||
row.duration_days !== null &&
|
||||
row.duration_days !== undefined
|
||||
? `${row.duration_days}天`
|
||||
: '-'
|
||||
},
|
||||
{
|
||||
prop: 'calendar_type',
|
||||
label: '套餐周期类型',
|
||||
@@ -1002,8 +1053,8 @@
|
||||
formatter: (row: PackageResponse) => {
|
||||
const map: Record<string, string> = {
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
yearly: '每年',
|
||||
none: '不重置'
|
||||
}
|
||||
const dataResetCycle = row.data_reset_cycle
|
||||
@@ -1025,11 +1076,33 @@
|
||||
return map[expiryBase] || expiryBase
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'default_expiry_base_name',
|
||||
label: '套餐默认生效条件',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
prop: 'expiry_base_override_name',
|
||||
label: '分配覆盖生效条件',
|
||||
width: 150,
|
||||
formatter: (row: PackageResponse) => row.expiry_base_override_name || '跟随套餐默认'
|
||||
},
|
||||
{
|
||||
prop: 'effective_expiry_base_name',
|
||||
label: '最终生效条件',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
prop: 'created_at',
|
||||
label: '创建时间',
|
||||
width: 180,
|
||||
formatter: (row: PackageResponse) => formatDateTime(row.created_at)
|
||||
},
|
||||
{
|
||||
prop: 'updated_at',
|
||||
label: '更新时间',
|
||||
width: 180,
|
||||
formatter: (row: PackageResponse) => formatDateTime(row.updated_at)
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1080,7 +1153,7 @@
|
||||
// 监听流量重置周期变化
|
||||
watch(
|
||||
() => form.data_reset_cycle,
|
||||
(cycle) => {
|
||||
() => {
|
||||
// 流量重置周期变化时不需要清空套餐天数,套餐天数只受calendar_type控制
|
||||
}
|
||||
)
|
||||
@@ -1097,7 +1170,7 @@
|
||||
|
||||
// 监听 is_gift 变化,自动设置 suggested_retail_price
|
||||
const handleIsGiftChange = (isGift: string | number | boolean) => {
|
||||
if (Boolean(isGift)) {
|
||||
if (isGift) {
|
||||
form.suggested_retail_price = 0
|
||||
} else {
|
||||
form.suggested_retail_price = undefined
|
||||
@@ -1195,7 +1268,7 @@
|
||||
series_id: searchForm.series_id || undefined,
|
||||
package_type: searchForm.package_type || undefined,
|
||||
shelf_status: searchForm.shelf_status || undefined,
|
||||
status: searchForm.status || undefined
|
||||
status: searchForm.status ?? undefined
|
||||
}
|
||||
const res = await PackageManageService.getPackages(params)
|
||||
if (res.code === 0) {
|
||||
@@ -1210,6 +1283,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 导出查询参数:保留 status=0(禁用)等有效零值
|
||||
const exportQuery = computed(() => {
|
||||
const query: Record<string, unknown> = {
|
||||
package_name: searchForm.package_name || undefined,
|
||||
series_id: searchForm.series_id ?? undefined,
|
||||
package_type: searchForm.package_type || undefined,
|
||||
shelf_status: searchForm.shelf_status ?? undefined,
|
||||
status: searchForm.status ?? undefined
|
||||
}
|
||||
|
||||
Object.keys(query).forEach((key) => {
|
||||
if (query[key] === undefined || query[key] === null || query[key] === '') {
|
||||
delete query[key]
|
||||
}
|
||||
})
|
||||
|
||||
return query
|
||||
})
|
||||
|
||||
// 重置搜索
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { ...initialSearchState })
|
||||
@@ -1387,7 +1479,6 @@
|
||||
const costPriceInCents = Math.round(form.cost_price * 100)
|
||||
|
||||
const data: any = {
|
||||
package_code: form.package_code,
|
||||
package_name: form.package_name,
|
||||
package_type: form.package_type,
|
||||
duration_months: form.duration_months,
|
||||
@@ -1395,8 +1486,12 @@
|
||||
is_gift: form.is_gift
|
||||
}
|
||||
|
||||
if (dialogType.value === 'add') {
|
||||
data.package_code = form.package_code
|
||||
}
|
||||
|
||||
// 可选字段
|
||||
if (form.series_id !== undefined && form.series_id !== null) {
|
||||
if (form.series_id !== undefined) {
|
||||
data.series_id = form.series_id
|
||||
}
|
||||
if (form.calendar_type) {
|
||||
@@ -1458,7 +1553,7 @@
|
||||
// 状态切换
|
||||
const handleStatusChange = async (row: PackageResponse, newFrontendStatus: number) => {
|
||||
const oldStatus = row.status
|
||||
const newApiStatus = frontendStatusToApi(newFrontendStatus)
|
||||
const newApiStatus = newFrontendStatus
|
||||
row.status = newApiStatus
|
||||
try {
|
||||
await PackageManageService.updatePackageStatus(row.id, newApiStatus)
|
||||
|
||||
@@ -398,7 +398,6 @@
|
||||
getStatusText,
|
||||
frontendStatusToApi,
|
||||
apiStatusToFrontend,
|
||||
STATUS_SELECT_OPTIONS,
|
||||
ENABLE_STATUS_OPTIONS,
|
||||
getEnableStatusText
|
||||
} from '@/config/constants'
|
||||
@@ -406,6 +405,11 @@
|
||||
|
||||
defineOptions({ name: 'PackageSeries' })
|
||||
|
||||
const PACKAGE_SERIES_STATUS_SELECT_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 2 }
|
||||
]
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const canUpdatePackageSeriesStatus = hasAuth('package_series:update_status')
|
||||
const router = useRouter()
|
||||
@@ -455,7 +459,7 @@
|
||||
clearable: true,
|
||||
placeholder: '请选择状态'
|
||||
},
|
||||
options: STATUS_SELECT_OPTIONS
|
||||
options: PACKAGE_SERIES_STATUS_SELECT_OPTIONS
|
||||
}
|
||||
]
|
||||
|
||||
@@ -788,7 +792,7 @@
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
series_name: searchForm.series_name || undefined,
|
||||
status: searchForm.status || undefined,
|
||||
status: searchForm.status ?? undefined,
|
||||
enable_one_time_commission: searchForm.enable_one_time_commission ?? undefined
|
||||
}
|
||||
const res = await PackageSeriesService.getPackageSeries(params)
|
||||
|
||||
@@ -348,7 +348,8 @@
|
||||
GrantPackageItem,
|
||||
GrantPackageInfo,
|
||||
PackageAllocationExpiryBaseOverride,
|
||||
PackageSeriesResponse
|
||||
PackageSeriesResponse,
|
||||
PackageResponse
|
||||
} from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { getEnableStatusText } from '@/config/constants'
|
||||
@@ -550,23 +551,31 @@
|
||||
|
||||
packageLoading.value = true
|
||||
try {
|
||||
const pageSize = 100
|
||||
const allPackages: PackageResponse[] = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
|
||||
do {
|
||||
const params: any = {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
series_id: detailData.value.series_id
|
||||
}
|
||||
|
||||
if (packageName) {
|
||||
params.package_name = packageName
|
||||
}
|
||||
if (packageName) params.package_name = packageName
|
||||
|
||||
const res = await PackageManageService.getPackages(params)
|
||||
if (res.code === 0) {
|
||||
if (res.code !== 0) break
|
||||
|
||||
allPackages.push(...(res.data.items || []))
|
||||
total = res.data.total || allPackages.length
|
||||
page += 1
|
||||
} while (allPackages.length < total)
|
||||
|
||||
availablePackages.value = mergeGrantPackageCandidates(
|
||||
res.data.items,
|
||||
allPackages,
|
||||
detailData.value.packages || []
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载套餐选项失败:', error)
|
||||
} finally {
|
||||
|
||||
@@ -594,12 +594,16 @@
|
||||
getStatusText,
|
||||
frontendStatusToApi,
|
||||
apiStatusToFrontend,
|
||||
STATUS_SELECT_OPTIONS,
|
||||
getEnableStatusText
|
||||
} from '@/config/constants'
|
||||
|
||||
defineOptions({ name: 'SeriesGrants' })
|
||||
|
||||
const SERIES_GRANT_STATUS_SELECT_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 2 }
|
||||
]
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const canUpdateSeriesGrantStatus = hasAuth('series_grants:update_status')
|
||||
const router = useRouter()
|
||||
@@ -736,7 +740,7 @@
|
||||
clearable: true,
|
||||
placeholder: '请选择状态'
|
||||
},
|
||||
options: STATUS_SELECT_OPTIONS
|
||||
options: SERIES_GRANT_STATUS_SELECT_OPTIONS
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1074,25 +1078,26 @@
|
||||
const loadPackageOptions = async (packageName?: string) => {
|
||||
packageLoading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: 1,
|
||||
page_size: 20
|
||||
}
|
||||
const pageSize = 100
|
||||
const allPackages: PackageResponse[] = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
|
||||
// 如果已选择套餐系列,则根据系列ID过滤套餐
|
||||
if (form.series_id) {
|
||||
params.series_id = form.series_id
|
||||
}
|
||||
|
||||
if (packageName) {
|
||||
params.package_name = packageName
|
||||
}
|
||||
do {
|
||||
const params: any = { page, page_size: pageSize }
|
||||
if (form.series_id) params.series_id = form.series_id
|
||||
if (packageName) params.package_name = packageName
|
||||
|
||||
const res = await PackageManageService.getPackages(params)
|
||||
if (res.code === 0) {
|
||||
if (res.code !== 0) break
|
||||
|
||||
allPackages.push(...(res.data.items || []))
|
||||
total = res.data.total || allPackages.length
|
||||
page += 1
|
||||
} while (allPackages.length < total)
|
||||
|
||||
// 过滤掉赠送套餐
|
||||
packageOptions.value = res.data.items.filter((pkg) => !pkg.is_gift)
|
||||
}
|
||||
packageOptions.value = allPackages.filter((pkg) => !pkg.is_gift)
|
||||
} catch (error) {
|
||||
console.error('加载套餐选项失败:', error)
|
||||
} finally {
|
||||
@@ -1125,7 +1130,7 @@
|
||||
// 获取套餐名称
|
||||
const getPackageName = (packageId: number) => {
|
||||
const pkg = packageOptions.value.find((p) => p.id === packageId)
|
||||
return pkg ? pkg.package_name : `套餐ID: ${packageId}`
|
||||
return pkg ? pkg.package_name : '套餐名称不可用'
|
||||
}
|
||||
|
||||
const getPackageCostPriceMax = (pkg?: {
|
||||
@@ -1452,7 +1457,7 @@
|
||||
series_id: searchForm.series_id || undefined,
|
||||
allocator_shop_id:
|
||||
searchForm.allocator_shop_id !== undefined ? searchForm.allocator_shop_id : undefined,
|
||||
status: searchForm.status || undefined
|
||||
status: searchForm.status ?? undefined
|
||||
}
|
||||
const res = await ShopSeriesGrantService.getShopSeriesGrants(params)
|
||||
if (res.code === 0) {
|
||||
|
||||
@@ -371,18 +371,20 @@
|
||||
|
||||
packageLoading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
series_id: seriesId.value
|
||||
}
|
||||
if (packageName) {
|
||||
params.package_name = packageName
|
||||
}
|
||||
const pageSize = 100
|
||||
const allPackages: any[] = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
do {
|
||||
const params: any = { page, page_size: pageSize, series_id: seriesId.value }
|
||||
if (packageName) params.package_name = packageName
|
||||
const res = await PackageManageService.getPackages(params)
|
||||
if (res.code === 0) {
|
||||
availablePackages.value = mergeGrantPackageCandidates(res.data.items, packageList.value)
|
||||
}
|
||||
if (res.code !== 0) break
|
||||
allPackages.push(...(res.data.items || []))
|
||||
total = res.data.total || allPackages.length
|
||||
page += 1
|
||||
} while (allPackages.length < total)
|
||||
availablePackages.value = mergeGrantPackageCandidates(allPackages, packageList.value)
|
||||
} catch (error) {
|
||||
console.error('加载套餐选项失败:', error)
|
||||
} finally {
|
||||
|
||||
@@ -51,7 +51,12 @@
|
||||
<span>{{ currentConfig?.description || '-' }}</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="配置值">
|
||||
<ElSwitch v-if="isBooleanConfig" v-model="editBoolean" />
|
||||
<ElCheckboxGroup v-if="isPaymentConfig" v-model="editPaymentMethods">
|
||||
<ElCheckbox v-for="method in paymentMethods" :key="method" :label="method">
|
||||
{{ method }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
<ElSwitch v-else-if="isBooleanConfig" v-model="editBoolean" />
|
||||
<ElInputNumber
|
||||
v-else-if="isIntegerConfig"
|
||||
v-model="editNumber"
|
||||
@@ -101,11 +106,20 @@
|
||||
import { ElMessage, ElTag } from 'element-plus'
|
||||
import { SystemConfigService } from '@/api/modules'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { SystemConfigItem, SystemConfigModule } from '@/types/api/systemConfig'
|
||||
import {
|
||||
PAYMENT_CONFIG_KEYS,
|
||||
parsePaymentMethods,
|
||||
type AllowedPaymentMethod,
|
||||
type SystemConfigItem,
|
||||
type SystemConfigModule
|
||||
} from '@/types/api/systemConfig'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
defineOptions({ name: 'SystemConfigs' })
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const moduleOptions = [
|
||||
{ label: '运营商回调配置', value: 'carrier_callback' },
|
||||
@@ -133,6 +147,8 @@
|
||||
const originalValue = ref('')
|
||||
const editBoolean = ref(false)
|
||||
const editNumber = ref<number | null>(null)
|
||||
const editPaymentMethods = ref<AllowedPaymentMethod[]>([])
|
||||
const paymentMethods: AllowedPaymentMethod[] = ['wallet', 'wechat', 'alipay']
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const columnOptions = [
|
||||
@@ -198,6 +214,11 @@
|
||||
const isBooleanConfig = computed(
|
||||
() => currentConfig.value?.value_type === 'bool' || currentConfig.value?.control === 'switch'
|
||||
)
|
||||
const isPaymentConfig = computed(() =>
|
||||
[PAYMENT_CONFIG_KEYS.card, PAYMENT_CONFIG_KEYS.device].includes(
|
||||
currentConfig.value?.config_key as (typeof PAYMENT_CONFIG_KEYS)[keyof typeof PAYMENT_CONFIG_KEYS]
|
||||
)
|
||||
)
|
||||
const isIntegerConfig = computed(
|
||||
() => !isBooleanConfig.value && currentConfig.value?.value_type === 'int'
|
||||
)
|
||||
@@ -222,6 +243,7 @@
|
||||
})
|
||||
|
||||
const getSubmittedValue = () => {
|
||||
if (isPaymentConfig.value) return JSON.stringify(editPaymentMethods.value)
|
||||
if (isBooleanConfig.value) return String(editBoolean.value)
|
||||
if (isIntegerConfig.value) return editNumber.value === null ? '' : String(editNumber.value)
|
||||
return editValue.value
|
||||
@@ -246,12 +268,17 @@
|
||||
return '请输入有效的 JSON'
|
||||
}
|
||||
}
|
||||
if (isPaymentConfig.value) {
|
||||
if (editPaymentMethods.value.length === 0) return '至少保留一种支付方式'
|
||||
}
|
||||
if (value === '') return '配置值不能为空'
|
||||
return ''
|
||||
}
|
||||
|
||||
const getActions = (row: SystemConfigItem) =>
|
||||
row.readonly
|
||||
row.readonly ||
|
||||
([PAYMENT_CONFIG_KEYS.card, PAYMENT_CONFIG_KEYS.device].includes(row.config_key as never) &&
|
||||
!hasAuth(JULY_PERMISSIONS.systemConfig.payment))
|
||||
? []
|
||||
: [{ label: '编辑', handler: () => showEditDialog(row), type: 'primary' as const }]
|
||||
|
||||
@@ -280,6 +307,7 @@
|
||||
editValue.value = row.value
|
||||
editBoolean.value = row.value === 'true'
|
||||
editNumber.value = /^-?\d+$/.test(row.value) ? Number(row.value) : null
|
||||
editPaymentMethods.value = parsePaymentMethods(row.value)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
|
||||
253
src/views/settings/wecom/applications/detail.vue
Normal file
253
src/views/settings/wecom/applications/detail.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="wecom-application-detail-page">
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">{{ isEdit ? '编辑企微应用' : '新增企微应用' }}</div>
|
||||
<div class="page-description"
|
||||
>应用凭据由平台管理员维护,保存后可在应用列表测试连接。</div
|
||||
>
|
||||
</div>
|
||||
<ElButton @click="goBack">返回应用列表</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="140px"
|
||||
class="application-form"
|
||||
>
|
||||
<ElRow :gutter="24">
|
||||
<ElCol :xs="24" :sm="12">
|
||||
<ElFormItem label="企业 ID" prop="corp_id">
|
||||
<ElInput v-model="form.corp_id" placeholder="请输入企业微信 CorpID" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12">
|
||||
<ElFormItem label="AgentID" prop="agent_id">
|
||||
<ElInputNumber
|
||||
v-model="form.agent_id"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
class="full-width"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12">
|
||||
<ElFormItem label="应用名称" prop="name">
|
||||
<ElInput v-model="form.name" placeholder="请输入应用名称" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12">
|
||||
<ElFormItem label="Secret" prop="secret">
|
||||
<ElInput
|
||||
v-model="form.secret"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入应用 Secret"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ElDivider content-position="left">回调配置</ElDivider>
|
||||
<ElRow :gutter="24">
|
||||
<ElCol :xs="24" :sm="12">
|
||||
<ElFormItem label="回调 Token" prop="callback_token">
|
||||
<ElInput v-model="form.callback_token" placeholder="请输入回调 Token" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12">
|
||||
<ElFormItem label="EncodingAESKey" prop="encoding_aes_key">
|
||||
<ElInput v-model="form.encoding_aes_key" placeholder="请输入 43 位 EncodingAESKey" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ElFormItem label="启用状态">
|
||||
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="禁用" />
|
||||
</ElFormItem>
|
||||
|
||||
<div class="form-actions">
|
||||
<ElButton @click="goBack">取消</ElButton>
|
||||
<ElButton
|
||||
v-if="isEdit"
|
||||
v-permission="JULY_PERMISSIONS.wecom.application"
|
||||
:loading="testing"
|
||||
@click="testApplication"
|
||||
>
|
||||
测试连接
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.application"
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="saveApplication"
|
||||
>
|
||||
保存应用
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { WecomService } from '@/api/modules'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import type { WecomApplication } from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'WecomApplicationDetail' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const applicationId = computed(() => Number(route.params.id) || 0)
|
||||
const isEdit = computed(() => applicationId.value > 0)
|
||||
const form = reactive({
|
||||
corp_id: '',
|
||||
agent_id: 0,
|
||||
name: '',
|
||||
secret: '',
|
||||
callback_token: '',
|
||||
encoding_aes_key: '',
|
||||
enabled: true
|
||||
})
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
corp_id: [{ required: true, message: '请输入企业 ID', trigger: 'blur' }],
|
||||
agent_id: [{ required: true, message: '请输入 AgentID', trigger: 'change' }],
|
||||
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
|
||||
secret: [{ required: true, message: '请输入 Secret', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
const fillForm = (application?: WecomApplication) => {
|
||||
Object.assign(
|
||||
form,
|
||||
application
|
||||
? {
|
||||
corp_id: application.corp_id,
|
||||
agent_id: application.agent_id,
|
||||
name: application.name,
|
||||
secret: application.secret || '',
|
||||
callback_token: application.callback_token || '',
|
||||
encoding_aes_key: application.encoding_aes_key || '',
|
||||
enabled: application.status === 1
|
||||
}
|
||||
: {
|
||||
corp_id: '',
|
||||
agent_id: 0,
|
||||
name: '',
|
||||
secret: '',
|
||||
callback_token: '',
|
||||
encoding_aes_key: '',
|
||||
enabled: true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const loadApplication = async () => {
|
||||
if (!isEdit.value) {
|
||||
fillForm()
|
||||
return
|
||||
}
|
||||
const response = await WecomService.getApplications({ page: 1, page_size: 100 })
|
||||
if (response.code === 0) {
|
||||
const application = response.data.items?.find((item) => item.id === applicationId.value)
|
||||
if (application) fillForm(application)
|
||||
else {
|
||||
ElMessage.error('未找到对应的企微应用')
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const saveApplication = async () => {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await WecomService.saveApplication({
|
||||
corp_id: form.corp_id,
|
||||
agent_id: form.agent_id,
|
||||
name: form.name,
|
||||
secret: form.secret,
|
||||
callback_token: form.callback_token,
|
||||
encoding_aes_key: form.encoding_aes_key,
|
||||
status: form.enabled ? 1 : 0
|
||||
})
|
||||
if (response.code === 0) {
|
||||
ElMessage.success('企微应用保存成功')
|
||||
await router.push(RoutesAlias.WecomApplications)
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testApplication = async () => {
|
||||
if (!applicationId.value) return
|
||||
testing.value = true
|
||||
try {
|
||||
const response = await WecomService.testApplication(applicationId.value)
|
||||
if (response.code === 0) {
|
||||
if (response.data.success) ElMessage.success('连接成功,已取得 access_token')
|
||||
else ElMessage.warning('连接失败,请检查应用凭据')
|
||||
}
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => void router.push(RoutesAlias.WecomApplications)
|
||||
|
||||
onMounted(() => void loadApplication())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wecom-application-detail-page {
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
margin-top: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.application-form {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
171
src/views/settings/wecom/applications/index.vue
Normal file
171
src/views/settings/wecom/applications/index.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="wecom-applications-page">
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">企微应用管理</div>
|
||||
<div class="page-description">管理企业微信应用凭据,并查看连接和默认发起人状态。</div>
|
||||
</div>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.application"
|
||||
type="primary"
|
||||
@click="goToCreate"
|
||||
>
|
||||
新增应用
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElTable v-loading="loading" :data="applications" border>
|
||||
<ElTableColumn prop="name" label="应用名称" min-width="160" show-overflow-tooltip />
|
||||
<ElTableColumn label="凭据状态" width="110">
|
||||
<template #default="scope">
|
||||
<ElTag :type="scope.row.credentials_set ? 'success' : 'warning'" size="small">
|
||||
{{ scope.row.credentials_set ? '已完整' : '未完整' }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="启用状态" width="100">
|
||||
<template #default="scope">
|
||||
<ElTag :type="scope.row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ scope.row.status_name }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="默认发起人" min-width="150">
|
||||
<template #default="scope">{{ scope.row.default_creator_name || '未设置' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="最近连接" min-width="170">
|
||||
<template #default="scope">{{ formatDate(scope.row.last_connected_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="300" fixed="right">
|
||||
<template #default="scope">
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.application"
|
||||
link
|
||||
type="primary"
|
||||
@click="testApplication(scope.row.id)"
|
||||
>
|
||||
测试连接
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.member"
|
||||
link
|
||||
type="primary"
|
||||
@click="goToMembers(scope.row.id)"
|
||||
>
|
||||
成员管理
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.application"
|
||||
link
|
||||
@click="goToDetail(scope.row.id)"
|
||||
>
|
||||
编辑配置
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<ElPagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.page_size"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="loadApplications"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { WecomService } from '@/api/modules'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import type { WecomApplication } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
|
||||
defineOptions({ name: 'WecomApplications' })
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const applications = ref<WecomApplication[]>([])
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const formatDate = (value?: string | null) => (value ? formatDateTime(value) : '-')
|
||||
|
||||
const loadApplications = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await WecomService.getApplications({
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size
|
||||
})
|
||||
if (response.code === 0) {
|
||||
applications.value = response.data.items || []
|
||||
pagination.total = response.data.total || 0
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.page_size = size
|
||||
pagination.page = 1
|
||||
void loadApplications()
|
||||
}
|
||||
|
||||
const goToCreate = () => void router.push(`${RoutesAlias.WecomApplications}/create`)
|
||||
const goToDetail = (id: number) => void router.push(`${RoutesAlias.WecomApplicationDetail}/${id}`)
|
||||
const goToMembers = (id: number) =>
|
||||
void router.push({ path: RoutesAlias.WecomMembers, query: { application_id: String(id) } })
|
||||
|
||||
const testApplication = async (id: number) => {
|
||||
const response = await WecomService.testApplication(id)
|
||||
if (response.code === 0) {
|
||||
if (response.data.success) ElMessage.success('连接成功,已取得 access_token')
|
||||
else ElMessage.warning('连接失败,请检查应用凭据')
|
||||
await loadApplications()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void loadApplications())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wecom-applications-page {
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
margin-top: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
424
src/views/settings/wecom/members/index.vue
Normal file
424
src/views/settings/wecom/members/index.vue
Normal file
@@ -0,0 +1,424 @@
|
||||
<template>
|
||||
<div class="wecom-members-page">
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">企微成员管理</div>
|
||||
<div class="page-description"
|
||||
>同步应用可见成员,设置默认审批发起人,并完成平台账号绑定。</div
|
||||
>
|
||||
</div>
|
||||
<ElButton @click="goToApplications">返回应用列表</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElForm inline class="search-form" @submit.prevent>
|
||||
<ElFormItem label="企微应用">
|
||||
<ElSelect
|
||||
v-model="selectedApplicationId"
|
||||
filterable
|
||||
class="application-select"
|
||||
placeholder="请选择企微应用"
|
||||
@change="handleApplicationChange"
|
||||
>
|
||||
<ElOption
|
||||
v-for="application in applications"
|
||||
:key="application.id"
|
||||
:label="application.name"
|
||||
:value="application.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="成员搜索">
|
||||
<ElInput
|
||||
v-model="keyword"
|
||||
clearable
|
||||
placeholder="姓名"
|
||||
class="keyword-input"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem>
|
||||
<ElButton type="primary" @click="handleSearch">查询</ElButton>
|
||||
<ElButton @click="handleReset">重置</ElButton>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="selected-application">
|
||||
当前应用:<span>{{ selectedApplication?.name || '未选择' }}</span>
|
||||
<ElTag v-if="selectedApplication?.default_creator_name" type="success" size="small">
|
||||
默认发起人:{{ selectedApplication.default_creator_name }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.member"
|
||||
:disabled="!selectedApplicationId"
|
||||
:loading="syncing"
|
||||
@click="syncMembers"
|
||||
>
|
||||
同步成员
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
:data="members"
|
||||
row-key="userid"
|
||||
highlight-current-row
|
||||
border
|
||||
@row-click="selectMember"
|
||||
>
|
||||
<ElTableColumn label="选择" width="70" align="center">
|
||||
<template #default="scope">
|
||||
<ElRadio v-model="selectedUserid" :label="scope.row.userid">
|
||||
<span class="sr-only">选择 {{ scope.row.name }}</span>
|
||||
</ElRadio>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="name" label="成员姓名" min-width="150" />
|
||||
<ElTableColumn prop="synced_at" label="同步时间" min-width="180" />
|
||||
<ElTableColumn label="操作" width="230" fixed="right">
|
||||
<template #default="scope">
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.member"
|
||||
link
|
||||
type="primary"
|
||||
@click.stop="setDefaultCreator(scope.row)"
|
||||
>
|
||||
设为默认发起人
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.binding"
|
||||
link
|
||||
@click.stop="selectMember(scope.row)"
|
||||
>
|
||||
绑定账号
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<ElPagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.page_size"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="loadMembers"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard shadow="never" class="operation-card">
|
||||
<template #header>当前成员操作</template>
|
||||
<ElAlert
|
||||
v-if="selectedMember"
|
||||
:title="`已选择:${selectedMember.name}`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/>
|
||||
<ElEmpty v-else description="请先在上方列表选择成员" :image-size="70" />
|
||||
|
||||
<div class="operation-row">
|
||||
<span class="operation-label">绑定平台账号</span>
|
||||
<ElInputNumber
|
||||
v-model="bindingAccountId"
|
||||
:min="1"
|
||||
:disabled="!selectedMember"
|
||||
controls-position="right"
|
||||
placeholder="账号 ID"
|
||||
/>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.binding"
|
||||
type="primary"
|
||||
:disabled="!selectedMember || !bindingAccountId"
|
||||
:loading="binding"
|
||||
@click="bindAccount"
|
||||
>
|
||||
绑定账号
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.member"
|
||||
:disabled="!selectedMember"
|
||||
:loading="savingDefault"
|
||||
@click="saveSelectedDefaultCreator"
|
||||
>
|
||||
保存为默认发起人
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { AccountService, WecomService } from '@/api/modules'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import type { WecomApplication, WecomMember } from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'WecomMembers' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const savingDefault = ref(false)
|
||||
const binding = ref(false)
|
||||
const applications = ref<WecomApplication[]>([])
|
||||
const members = ref<WecomMember[]>([])
|
||||
const selectedApplicationId = ref<number>()
|
||||
const selectedUserid = ref('')
|
||||
const bindingAccountId = ref<number>()
|
||||
const keyword = ref('')
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const selectedApplication = computed(() =>
|
||||
applications.value.find((application) => application.id === selectedApplicationId.value)
|
||||
)
|
||||
const selectedMember = computed(() =>
|
||||
members.value.find((member) => member.userid === selectedUserid.value)
|
||||
)
|
||||
|
||||
const loadApplications = async () => {
|
||||
const response = await WecomService.getApplications({ page: 1, page_size: 100 })
|
||||
if (response.code !== 0) return
|
||||
applications.value = response.data.items || []
|
||||
const queryApplicationId = Number(route.query.application_id)
|
||||
selectedApplicationId.value =
|
||||
applications.value.find((application) => application.id === queryApplicationId)?.id ||
|
||||
applications.value[0]?.id
|
||||
}
|
||||
|
||||
const loadMembers = async () => {
|
||||
if (!selectedApplicationId.value) {
|
||||
members.value = []
|
||||
pagination.total = 0
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await WecomService.getMembers(selectedApplicationId.value, {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
keyword: keyword.value.trim() || undefined
|
||||
})
|
||||
if (response.code === 0) {
|
||||
members.value = response.data.items || []
|
||||
pagination.total = response.data.total || 0
|
||||
if (!members.value.some((member) => member.userid === selectedUserid.value)) {
|
||||
selectedUserid.value = ''
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplicationChange = () => {
|
||||
pagination.page = 1
|
||||
selectedUserid.value = ''
|
||||
void loadMembers()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
void loadMembers()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
keyword.value = ''
|
||||
pagination.page = 1
|
||||
void loadMembers()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.page_size = size
|
||||
pagination.page = 1
|
||||
void loadMembers()
|
||||
}
|
||||
|
||||
const selectMember = (member: WecomMember) => {
|
||||
selectedUserid.value = member.userid
|
||||
}
|
||||
|
||||
const syncMembers = async () => {
|
||||
if (!selectedApplicationId.value) return
|
||||
syncing.value = true
|
||||
try {
|
||||
const response = await WecomService.syncMembers(selectedApplicationId.value)
|
||||
if (response.code === 0) {
|
||||
ElMessage.success(`已同步 ${response.data.synced_count} 名成员`)
|
||||
await loadMembers()
|
||||
}
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDefaultCreator = async (member: WecomMember) => {
|
||||
if (!selectedApplicationId.value) return
|
||||
selectedUserid.value = member.userid
|
||||
await saveSelectedDefaultCreator()
|
||||
}
|
||||
|
||||
const saveSelectedDefaultCreator = async () => {
|
||||
if (!selectedApplicationId.value || !selectedMember.value) {
|
||||
ElMessage.warning('请选择要设置的成员')
|
||||
return
|
||||
}
|
||||
savingDefault.value = true
|
||||
try {
|
||||
const response = await WecomService.setDefaultCreator(
|
||||
selectedApplicationId.value,
|
||||
selectedMember.value.userid
|
||||
)
|
||||
if (response.code === 0) {
|
||||
ElMessage.success('默认发起人已保存')
|
||||
const application = selectedApplication.value
|
||||
if (application) {
|
||||
application.default_creator_userid = selectedMember.value.userid
|
||||
application.default_creator_name = selectedMember.value.name
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
savingDefault.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const bindAccount = async () => {
|
||||
if (!selectedApplicationId.value || !selectedMember.value || !bindingAccountId.value) {
|
||||
ElMessage.warning('请选择成员并输入账号 ID')
|
||||
return
|
||||
}
|
||||
binding.value = true
|
||||
try {
|
||||
const response = await AccountService.bindWecom(bindingAccountId.value, {
|
||||
application_id: selectedApplicationId.value,
|
||||
userid: selectedMember.value.userid
|
||||
})
|
||||
if (response.code === 0) ElMessage.success('账号企微绑定成功')
|
||||
} finally {
|
||||
binding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goToApplications = () => void router.push(RoutesAlias.WecomApplications)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadApplications()
|
||||
await loadMembers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wecom-members-page {
|
||||
.page-header,
|
||||
.toolbar,
|
||||
.operation-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.toolbar {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
margin-top: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-top: 4px;
|
||||
padding: 14px 16px 0;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.application-select {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin: 20px 0 12px;
|
||||
|
||||
.selected-application {
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
span {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.operation-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.operation-row {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.operation-label {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header,
|
||||
.toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.application-select,
|
||||
.keyword-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
375
src/views/settings/wecom/scenes/index.vue
Normal file
375
src/views/settings/wecom/scenes/index.vue
Normal file
@@ -0,0 +1,375 @@
|
||||
<template>
|
||||
<div class="wecom-scenes-page">
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">企微审批场景</div>
|
||||
<div class="page-description"
|
||||
>分别维护退款和线下代充值审批模板,并提交后由后端校验控件映射。</div
|
||||
>
|
||||
</div>
|
||||
<ElButton v-permission="JULY_PERMISSIONS.wecom.scene" type="primary" @click="createScene">
|
||||
新增场景配置
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="scene-layout">
|
||||
<div class="scene-list">
|
||||
<div class="section-heading">场景配置列表</div>
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
:data="scenes"
|
||||
row-key="business_type"
|
||||
highlight-current-row
|
||||
border
|
||||
@row-click="selectScene"
|
||||
>
|
||||
<ElTableColumn prop="business_type_name" label="业务类型" min-width="150" />
|
||||
<ElTableColumn
|
||||
prop="template_name"
|
||||
label="模板名称"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn label="状态" width="90">
|
||||
<template #default="scope">
|
||||
<ElTag :type="scope.row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ scope.row.status_name }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="last_verified_at" label="最近校验" min-width="170" />
|
||||
<ElTableColumn label="操作" width="90" fixed="right">
|
||||
<template #default="scope">
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.scene"
|
||||
link
|
||||
type="primary"
|
||||
@click.stop="selectScene(scope.row)"
|
||||
>
|
||||
编辑
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<ElCard shadow="never" class="editor-card">
|
||||
<template #header>
|
||||
<div class="editor-header">
|
||||
<span>{{ editing ? '编辑审批场景' : '新增审批场景' }}</span>
|
||||
<ElTag v-if="editing" size="small" type="info">{{ sceneForm.business_type }}</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElForm :model="sceneForm" label-width="110px">
|
||||
<ElFormItem label="业务类型" required>
|
||||
<ElSelect v-model="sceneForm.business_type" :disabled="editing" class="full-width">
|
||||
<ElOption label="退款审批" value="refund_approval" />
|
||||
<ElOption label="线下代充值审批" value="offline_recharge_approval" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="企微应用">
|
||||
<ElSelect v-model="sceneForm.application_id" clearable filterable class="full-width">
|
||||
<ElOption
|
||||
v-for="application in applications"
|
||||
:key="application.id"
|
||||
:label="application.name"
|
||||
:value="application.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="模板 ID" required>
|
||||
<ElInput v-model="sceneForm.template_id" placeholder="请输入企微后台模板 ID" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="启用状态">
|
||||
<ElSwitch v-model="sceneForm.enabled" active-text="启用" inactive-text="禁用" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElDivider content-position="left">控件映射</ElDivider>
|
||||
<div class="mapping-heading">
|
||||
<span>业务字段与模板控件的对应关系</span>
|
||||
<ElButton text type="primary" @click="addMapping()">新增映射</ElButton>
|
||||
</div>
|
||||
<div v-if="mappingRows.length" class="mapping-list">
|
||||
<div v-for="(mapping, index) in mappingRows" :key="mapping.key" class="mapping-row">
|
||||
<ElInput v-model="mapping.business_field" placeholder="业务字段,如 refund_no" />
|
||||
<ElInput v-model="mapping.control_id" placeholder="控件 ID" />
|
||||
<ElInput v-model="mapping.control_type" placeholder="控件类型,如 Text" />
|
||||
<ElInput
|
||||
v-model="mapping.option_mapping"
|
||||
placeholder="选择项映射 JSON,可为空对象"
|
||||
/>
|
||||
<ElButton text type="danger" @click="removeMapping(index)">删除</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else description="暂无控件映射,请新增一条" :image-size="60" />
|
||||
|
||||
<div class="editor-actions">
|
||||
<ElButton @click="createScene">清空表单</ElButton>
|
||||
<ElButton
|
||||
v-permission="JULY_PERMISSIONS.wecom.scene"
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="saveScene"
|
||||
>
|
||||
保存并校验
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { WecomService } from '@/api/modules'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import type {
|
||||
WecomApplication,
|
||||
WecomBusinessType,
|
||||
WecomScene,
|
||||
WecomSceneControlMapping
|
||||
} from '@/types/api'
|
||||
|
||||
defineOptions({ name: 'WecomScenes' })
|
||||
|
||||
interface MappingEditor {
|
||||
key: number
|
||||
business_field: string
|
||||
control_id: string
|
||||
control_type: string
|
||||
option_mapping: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const applications = ref<WecomApplication[]>([])
|
||||
const scenes = ref<WecomScene[]>([])
|
||||
const selectedBusinessType = ref<WecomBusinessType>()
|
||||
const nextMappingKey = ref(1)
|
||||
const sceneForm = reactive({
|
||||
business_type: 'refund_approval' as WecomBusinessType,
|
||||
application_id: undefined as number | undefined,
|
||||
template_id: '',
|
||||
enabled: true
|
||||
})
|
||||
const mappingRows = ref<MappingEditor[]>([])
|
||||
const editing = computed(() => Boolean(selectedBusinessType.value))
|
||||
|
||||
const addMapping = (mapping?: Partial<MappingEditor>) => {
|
||||
mappingRows.value.push({
|
||||
key: nextMappingKey.value++,
|
||||
business_field: mapping?.business_field || '',
|
||||
control_id: mapping?.control_id || '',
|
||||
control_type: mapping?.control_type || '',
|
||||
option_mapping: mapping?.option_mapping || '{}'
|
||||
})
|
||||
}
|
||||
|
||||
const removeMapping = (index: number) => mappingRows.value.splice(index, 1)
|
||||
|
||||
const fillEditor = (scene?: WecomScene) => {
|
||||
if (!scene) {
|
||||
selectedBusinessType.value = undefined
|
||||
Object.assign(sceneForm, {
|
||||
business_type: 'refund_approval' as WecomBusinessType,
|
||||
application_id: applications.value[0]?.id,
|
||||
template_id: '',
|
||||
enabled: true
|
||||
})
|
||||
mappingRows.value = []
|
||||
addMapping()
|
||||
return
|
||||
}
|
||||
selectedBusinessType.value = scene.business_type
|
||||
Object.assign(sceneForm, {
|
||||
business_type: scene.business_type,
|
||||
application_id: scene.application_id,
|
||||
template_id: scene.template_id,
|
||||
enabled: scene.status === 1
|
||||
})
|
||||
mappingRows.value = (scene.control_mapping || []).map((mapping) => ({
|
||||
key: nextMappingKey.value++,
|
||||
business_field: mapping.business_field,
|
||||
control_id: mapping.control_id,
|
||||
control_type: mapping.control_type,
|
||||
option_mapping: JSON.stringify(mapping.option_mapping || {})
|
||||
}))
|
||||
}
|
||||
|
||||
const selectScene = (scene: WecomScene) => fillEditor(scene)
|
||||
const createScene = () => fillEditor()
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [applicationResponse, sceneResponse] = await Promise.all([
|
||||
WecomService.getApplications({ page: 1, page_size: 100 }),
|
||||
WecomService.getScenes({ page: 1, page_size: 100 })
|
||||
])
|
||||
if (applicationResponse.code === 0) applications.value = applicationResponse.data.items || []
|
||||
if (sceneResponse.code === 0) {
|
||||
scenes.value = sceneResponse.data.items || []
|
||||
const current = scenes.value.find(
|
||||
(scene) => scene.business_type === selectedBusinessType.value
|
||||
)
|
||||
fillEditor(current || scenes.value[0])
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const parseMapping = (): WecomSceneControlMapping[] | undefined => {
|
||||
const result: WecomSceneControlMapping[] = []
|
||||
for (const mapping of mappingRows.value) {
|
||||
if (!mapping.business_field && !mapping.control_id && !mapping.control_type) continue
|
||||
if (!mapping.business_field || !mapping.control_id || !mapping.control_type) {
|
||||
ElMessage.warning('请完整填写控件映射字段')
|
||||
return
|
||||
}
|
||||
let optionMapping: Record<string, string>
|
||||
try {
|
||||
const parsed = JSON.parse(mapping.option_mapping || '{}')
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object')
|
||||
throw new Error('invalid')
|
||||
optionMapping = parsed as Record<string, string>
|
||||
} catch {
|
||||
ElMessage.warning(`第 ${result.length + 1} 条选择项映射不是有效 JSON`)
|
||||
return
|
||||
}
|
||||
result.push({
|
||||
business_field: mapping.business_field,
|
||||
control_id: mapping.control_id,
|
||||
control_type: mapping.control_type,
|
||||
option_mapping: optionMapping
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const saveScene = async () => {
|
||||
if (!sceneForm.template_id.trim()) {
|
||||
ElMessage.warning('请输入模板 ID')
|
||||
return
|
||||
}
|
||||
const controlMapping = parseMapping()
|
||||
if (!controlMapping) return
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await WecomService.saveScene(sceneForm.business_type, {
|
||||
application_id: sceneForm.application_id,
|
||||
template_id: sceneForm.template_id.trim(),
|
||||
control_mapping: controlMapping,
|
||||
status: sceneForm.enabled ? 1 : 0
|
||||
})
|
||||
if (response.code === 0) {
|
||||
ElMessage.success('场景保存并校验成功')
|
||||
await loadData()
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void loadData())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wecom-scenes-page {
|
||||
.page-header,
|
||||
.editor-header,
|
||||
.mapping-heading,
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.editor-header,
|
||||
.mapping-heading {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
margin-top: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.scene-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(480px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: 12px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mapping-heading {
|
||||
margin-bottom: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mapping-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mapping-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 1fr 0.8fr 1.3fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.scene-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mapping-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -228,6 +228,13 @@
|
||||
:inactive-value="CommonStatus.DISABLED"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-if="dialogType === 'edit'" label="C 端登录">
|
||||
<ElSwitch
|
||||
v-model="formData.client_login_disabled"
|
||||
active-text="禁止新登录"
|
||||
inactive-text="允许登录"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
@@ -322,9 +329,11 @@
|
||||
import { ShopService, RoleService } from '@/api/modules'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type {
|
||||
CreateShopParams,
|
||||
ShopBusinessOwnerCandidate,
|
||||
ShopResponse,
|
||||
ShopRoleResponse
|
||||
ShopRoleResponse,
|
||||
UpdateShopParams
|
||||
} from '@/types/api'
|
||||
import { RoleType, RoleStatus } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
@@ -610,6 +619,7 @@
|
||||
formData.contact_name = row.contact_name || ''
|
||||
formData.contact_phone = row.contact_phone || ''
|
||||
formData.business_owner_account_id = row.business_owner_account_id ?? null
|
||||
formData.client_login_disabled = row.client_login_disabled
|
||||
formData.status = row.status
|
||||
formData.init_username = ''
|
||||
formData.init_password = ''
|
||||
@@ -628,6 +638,7 @@
|
||||
formData.contact_name = ''
|
||||
formData.contact_phone = ''
|
||||
formData.business_owner_account_id = null
|
||||
formData.client_login_disabled = false
|
||||
formData.status = CommonStatus.ENABLED
|
||||
formData.init_username = ''
|
||||
formData.init_password = ''
|
||||
@@ -736,6 +747,12 @@
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: ShopResponse) => formatBusinessOwner(row)
|
||||
},
|
||||
{
|
||||
prop: 'client_login_disabled',
|
||||
label: 'C 端登录',
|
||||
width: 110,
|
||||
formatter: (row: ShopResponse) => (row.client_login_disabled ? '已限制' : '正常')
|
||||
},
|
||||
...(canModifyShopStatus
|
||||
? [
|
||||
{
|
||||
@@ -825,7 +842,8 @@
|
||||
init_password: '',
|
||||
init_phone: '',
|
||||
default_role_id: undefined as number | undefined,
|
||||
business_owner_account_id: null as number | null
|
||||
business_owner_account_id: null as number | null,
|
||||
client_login_disabled: false
|
||||
})
|
||||
|
||||
// 处理编码生成
|
||||
@@ -987,14 +1005,15 @@
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (dialogType.value === 'add') {
|
||||
const data: any = {
|
||||
const data: CreateShopParams = {
|
||||
shop_name: formData.shop_name,
|
||||
shop_code: formData.shop_code,
|
||||
init_username: formData.init_username,
|
||||
init_password: formData.init_password,
|
||||
init_phone: formData.init_phone,
|
||||
default_role_id: formData.default_role_id,
|
||||
business_owner_account_id: formData.business_owner_account_id
|
||||
default_role_id: formData.default_role_id!,
|
||||
business_owner_account_id: formData.business_owner_account_id,
|
||||
client_login_disabled: formData.client_login_disabled
|
||||
}
|
||||
|
||||
// 可选字段 - parent_id 可能是数组(级联选择器)或数字
|
||||
@@ -1013,10 +1032,11 @@
|
||||
await ShopService.createShop(data)
|
||||
ElMessage.success('新增成功')
|
||||
} else {
|
||||
const data: any = {
|
||||
const data: UpdateShopParams = {
|
||||
shop_name: formData.shop_name,
|
||||
status: formData.status,
|
||||
business_owner_account_id: formData.business_owner_account_id
|
||||
business_owner_account_id: formData.business_owner_account_id,
|
||||
client_login_disabled: formData.client_login_disabled
|
||||
}
|
||||
|
||||
// 可选字段
|
||||
|
||||
@@ -247,8 +247,8 @@
|
||||
ElOption
|
||||
} from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { RoleType } from '@/types/api'
|
||||
import type { PlatformRole, PermissionTreeNode } from '@/types/api'
|
||||
import { RoleStatus, RoleType } from '@/types/api'
|
||||
import type { PlatformRole, PermissionTreeNode, PlatformRoleFormData } from '@/types/api'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
@@ -381,12 +381,18 @@
|
||||
]
|
||||
})
|
||||
|
||||
const form = reactive<any>({
|
||||
type RoleFormState = PlatformRoleFormData & {
|
||||
id: number
|
||||
credit_enabled: boolean
|
||||
credit_limit_yuan: number
|
||||
}
|
||||
|
||||
const form = reactive<RoleFormState>({
|
||||
id: 0,
|
||||
role_name: '',
|
||||
role_desc: '',
|
||||
role_type: 1,
|
||||
status: CommonStatus.ENABLED,
|
||||
role_type: RoleType.PLATFORM,
|
||||
status: RoleStatus.ENABLED,
|
||||
credit_enabled: false,
|
||||
credit_limit_yuan: 0
|
||||
})
|
||||
@@ -1138,7 +1144,7 @@
|
||||
form.role_name = ''
|
||||
form.role_desc = ''
|
||||
form.role_type = 1
|
||||
form.status = CommonStatus.ENABLED
|
||||
form.status = RoleStatus.ENABLED
|
||||
form.credit_enabled = false
|
||||
form.credit_limit_yuan = 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user