Compare commits
41 Commits
9f7f619083
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8229ad9248 | ||
|
|
fc01fa2ac9 | ||
|
|
65cc63674e | ||
|
|
01cddd7eda | ||
|
|
a7b006076a | ||
|
|
c36c59e268 | ||
|
|
e56951d4b7 | ||
|
|
b5c58d695f | ||
|
|
6f6ac83b71 | ||
|
|
48e4adf3ff | ||
|
|
d335984c20 | ||
|
|
89e6d19d1f | ||
|
|
889ff3bc28 | ||
|
|
d8191f03a0 | ||
|
|
4d023e7666 | ||
|
|
fe357f56d9 | ||
|
|
2cb961fd1b | ||
|
|
4e3ea6c6d6 | ||
|
|
2d6948010b | ||
|
|
95abcb97ef | ||
|
|
d9d07422a9 | ||
|
|
93f67967c5 | ||
|
|
857d565f33 | ||
|
|
9a982e0446 | ||
|
|
737ade3a5e | ||
|
|
0d2a982f69 | ||
|
|
3dd17e9d7b | ||
|
|
ca748999ec | ||
|
|
e86f61f3f6 | ||
|
|
10ee87bf49 | ||
|
|
32635da58c | ||
|
|
8627d203cf | ||
|
|
41f3b61b62 | ||
|
|
33485b137f | ||
|
|
9599a4d52b | ||
|
|
8650b490d7 | ||
|
|
373ad4c2a5 | ||
|
|
3a819e86c9 | ||
|
|
e9c0e17d6a | ||
|
|
db7b3562da | ||
|
|
f52970ae6c |
@@ -3,7 +3,7 @@ name: 构建并部署前端到测试环境
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
- dev
|
||||
- test
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- name: 设置镜像标签
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/develop" ]; then
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
||||
echo "tag=dev" >> $GITHUB_OUTPUT
|
||||
@@ -51,8 +51,8 @@ jobs:
|
||||
docker push ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
|
||||
docker push ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
|
||||
- name: 部署到本地(仅 develop 分支)
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
- name: 部署到本地(仅 main 分支)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
# 确保部署目录存在
|
||||
mkdir -p ${{ env.DEPLOY_DIR }}
|
||||
|
||||
50
openspec/changes/add-historical-approval-resend/design.md
Normal file
50
openspec/changes/add-historical-approval-resend/design.md
Normal file
@@ -0,0 +1,50 @@
|
||||
## Context
|
||||
|
||||
退款和代理充值已经接入企微审批,但部分历史记录在审批流程上线前创建,列表中的 `approval_status` 为空。这些记录需要由运营人员手动触发一次审批补发,才能进入企微审批链路。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
- Goals: 提供代理充值和退款的历史审批补发入口。
|
||||
- Goals: 通过路径参数指定目标记录,并复用后端返回的完整记录与审批状态。
|
||||
- Goals: 由明确的状态规则控制入口展示,后端做最终资格校验。
|
||||
- Non-Goals: 不在前端实现审批提交、审批回调或审批引擎。
|
||||
- Non-Goals: 不修改历史记录的业务字段、金额或支付/退款结果。
|
||||
- Non-Goals: 不提供批量自动补发。
|
||||
|
||||
## Decisions
|
||||
|
||||
- Decision: 两个模块分别新增 `triggerApproval(id)` 服务方法,返回完整业务记录并保留审批字段。
|
||||
- Rationale: 两个接口契约一致,成功后页面需要立即展示最新审批状态,完整记录可直接用于刷新。
|
||||
|
||||
- Decision: 代理充值仅在 `approval_status` 为空且 `status` 不属于已完成、已驳回、已关闭时显示补发入口;退款仅在 `status=待审批` 且 `approval_status` 为空时显示补发入口。
|
||||
- Rationale: 与业务规则一致,避免对已进入审批或已终结的记录重复补发;后端仍做最终资格校验。
|
||||
|
||||
- Decision: 引入独立按钮权限 `agent_recharge:trigger_approval` 和 `refund:trigger_approval`。
|
||||
- Rationale: 补发审批是财务相关敏感操作,需与现有确认支付、拒绝、重新申请权限区分。
|
||||
|
||||
- Decision: 补发审批与现有“确认支付/拒绝”和“重新申请”操作共存。
|
||||
- Rationale: 它们承担不同职责;补发审批只是把历史记录推进企微审批,不改变后续人工确认或重新申请的流程。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Risk: 代理充值中 `status=已支付`、`已退款` 等非终态记录是否允许补发,需要后端最终确认。
|
||||
- Mitigation: 前端按约定的审批状态与状态排除规则展示入口,后端对不允许的记录返回错误并稳定展示。
|
||||
|
||||
- Risk: 接口可能对部分状态返回拒绝。
|
||||
- Mitigation: 前端处理后端错误信息,不将失败记录标记为已补发。
|
||||
|
||||
- Risk: 重复点击可能触发多次审批提交。
|
||||
- Mitigation: 提交期间锁定按钮并禁用重复触发;后端应保证幂等或返回明确的已存在审批提示。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 确认两个 `trigger-approval` 接口的响应字段与现有 `AgentRecharge`、`Refund` 类型一致。
|
||||
2. 新增服务方法和按钮权限。
|
||||
3. 在列表接入补发审批入口及资格判断。
|
||||
4. 联调补发成功、接口拒绝、权限缺失、审批状态为空与状态不符合条件等场景。
|
||||
5. 验证与现有确认支付、拒绝、重新申请操作不冲突。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 后端对可补发记录的最终状态校验以及幂等性需确认。
|
||||
- 两个接口是否都需要独立权限码,还是复用现有审批/财务权限,需与后端权限配置对齐。
|
||||
39
openspec/changes/add-historical-approval-resend/proposal.md
Normal file
39
openspec/changes/add-historical-approval-resend/proposal.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Change: 补发历史线下代理充值审批与补发历史退款审批
|
||||
|
||||
## Why
|
||||
|
||||
历史线下代理充值记录和历史退款申请在企微审批流程上线前创建,列表中这些记录的审批状态为空,运营人员无法为它们补发审批流程。需要为这两类业务提供“补发审批”入口,调用后端触发审批接口,将历史记录纳入企微审批。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在 `AgentRechargeService` 新增 `triggerApproval(id)`,调用 `POST /api/admin/agent-recharges/{id}/trigger-approval`。
|
||||
- 在 `RefundService` 新增 `triggerApproval(id)`,调用 `POST /api/admin/refunds/{id}/trigger-approval`。
|
||||
- 在代理充值列表为 `approval_status` 为空且 `status` 不属于已完成、已驳回、已关闭的充值记录增加“补发审批”操作。
|
||||
- 在退款列表为 `status=待审批` 且 `approval_status` 为空的退款申请增加“补发审批”操作。
|
||||
- 引入权限 `agent_recharge:trigger_approval` 和 `refund:trigger_approval`,仅对有权限的平台账号展示操作。
|
||||
- 补发成功后刷新列表并展示返回的最新审批状态;失败时展示后端错误信息。
|
||||
- 不改变现有“确认支付”“拒绝”“重新申请”等操作的资格和职责。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs:
|
||||
- `agent-recharge`
|
||||
- `refund-management`
|
||||
- Affected code:
|
||||
- `src/api/modules/agentRecharge.ts`
|
||||
- `src/api/modules/refund.ts`
|
||||
- `src/types/api/agentRecharge.ts`
|
||||
- `src/types/api/refund.ts`
|
||||
- `src/views/finance/agent-recharge/agentRechargeActions.ts`
|
||||
- `src/views/finance/agent-recharge/index.vue`
|
||||
- `src/views/finance/refund/index.vue`
|
||||
- API contracts:
|
||||
- `POST /api/admin/agent-recharges/{id}/trigger-approval`
|
||||
- `POST /api/admin/refunds/{id}/trigger-approval`
|
||||
- Dependencies:
|
||||
- 后端按文档返回完整业务记录及当前审批状态。
|
||||
- 后端负责校验记录是否可补发审批,前端仅控制入口展示并处理后端拒绝。
|
||||
- Out of scope:
|
||||
- 企微审批的发起、撤回、通过、驳回或删除动作本身。
|
||||
- 修改历史记录的业务字段、金额或支付/退款结果。
|
||||
- 自动判断并批量补发历史审批。
|
||||
@@ -0,0 +1,56 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Agent Recharge Historical Approval Resend API
|
||||
|
||||
The agent recharge service SHALL expose a historical approval resend operation through `POST /api/admin/agent-recharges/{id}/trigger-approval`.
|
||||
|
||||
#### Scenario: Resend approval for a historical offline recharge
|
||||
|
||||
- **GIVEN** 一个需要补发审批的历史线下代理充值记录 `id`
|
||||
- **WHEN** 前端调用 `POST /api/admin/agent-recharges/{id}/trigger-approval`
|
||||
- **THEN** 请求 MUST 在路径参数中携带该充值记录 `id`
|
||||
- **AND** 成功响应 MUST 被解析为完整的 `AgentRecharge` 记录,并保留 `approval_provider`、`approval_instance_id`、`approval_status` 和 `approval_status_name` 字段
|
||||
|
||||
### Requirement: Agent Recharge Historical Approval Resend Entry
|
||||
|
||||
The agent recharge list page SHALL provide a `补发审批` action only for recharge records whose `approval_status` is empty and whose business `status` is not completed, rejected, or closed, and SHALL gate it by permission.
|
||||
|
||||
#### Scenario: Show resend action for an eligible recharge
|
||||
|
||||
- **GIVEN** 平台账号拥有 `agent_recharge:trigger_approval` 权限
|
||||
- **AND** 充值记录的 `approval_status` 为空(`null` 或 `undefined`)
|
||||
- **AND** 充值记录的 `status` 不属于已完成(3)、已驳回(6)、已关闭(4)
|
||||
- **WHEN** 页面渲染代理充值列表
|
||||
- **THEN** 页面 MUST 为该记录显示“补发审批”操作
|
||||
|
||||
#### Scenario: Hide resend action when approval status is present
|
||||
|
||||
- **GIVEN** 充值记录的 `approval_status` 不为空
|
||||
- **WHEN** 页面渲染该记录
|
||||
- **THEN** 页面 MUST NOT 显示“补发审批”操作
|
||||
|
||||
#### Scenario: Hide resend action for terminal recharge statuses
|
||||
|
||||
- **GIVEN** 充值记录的 `status` 为已完成(3)、已驳回(6)或已关闭(4)
|
||||
- **WHEN** 页面渲染该记录
|
||||
- **THEN** 页面 MUST NOT 显示“补发审批”操作
|
||||
|
||||
#### Scenario: Hide resend action without permission
|
||||
|
||||
- **GIVEN** 当前账号不拥有 `agent_recharge:trigger_approval` 权限
|
||||
- **WHEN** 页面渲染代理充值记录
|
||||
- **THEN** 页面 MUST NOT 显示“补发审批”操作
|
||||
|
||||
#### Scenario: Resend approval succeeds
|
||||
|
||||
- **GIVEN** 用户对符合条件的充值记录点击“补发审批”
|
||||
- **WHEN** 接口返回 `code=0`
|
||||
- **THEN** 页面 MUST 显示成功提示并刷新列表
|
||||
- **AND** 刷新后的记录 MUST 展示接口返回的最新审批状态
|
||||
|
||||
#### Scenario: Resend approval fails
|
||||
|
||||
- **GIVEN** 接口返回非零 `code` 或请求失败
|
||||
- **WHEN** 用户触发“补发审批”
|
||||
- **THEN** 页面 MUST 展示后端返回的错误信息
|
||||
- **AND** 页面 MUST NOT 将记录标记为已补发审批
|
||||
@@ -0,0 +1,56 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Refund Historical Approval Resend API
|
||||
|
||||
The refund service SHALL expose a historical approval resend operation through `POST /api/admin/refunds/{id}/trigger-approval`.
|
||||
|
||||
#### Scenario: Resend approval for a historical refund
|
||||
|
||||
- **GIVEN** 一个需要补发审批的历史退款申请 `id`
|
||||
- **WHEN** 前端调用 `POST /api/admin/refunds/{id}/trigger-approval`
|
||||
- **THEN** 请求 MUST 在路径参数中携带该退款申请 `id`
|
||||
- **AND** 成功响应 MUST 被解析为完整的 `Refund` 记录,并保留 `approval_provider`、`approval_instance_id`、`approval_status` 和 `approval_status_name` 字段
|
||||
|
||||
### Requirement: Refund Historical Approval Resend Entry
|
||||
|
||||
The refund list page SHALL provide a `补发审批` action only for refund records whose `status` is pending approval and whose `approval_status` is empty, and SHALL gate it by permission.
|
||||
|
||||
#### Scenario: Show resend action for an eligible refund
|
||||
|
||||
- **GIVEN** 平台账号拥有 `refund:trigger_approval` 权限
|
||||
- **AND** 退款申请的 `status` 为待审批(1)
|
||||
- **AND** 退款申请的 `approval_status` 为空(`null` 或 `undefined`)
|
||||
- **WHEN** 页面渲染退款列表
|
||||
- **THEN** 页面 MUST 为该记录显示“补发审批”操作
|
||||
|
||||
#### Scenario: Hide resend action when refund status is not pending
|
||||
|
||||
- **GIVEN** 退款申请的 `status` 不为待审批(1)
|
||||
- **WHEN** 页面渲染该记录
|
||||
- **THEN** 页面 MUST NOT 显示“补发审批”操作
|
||||
|
||||
#### Scenario: Hide resend action when approval status is present
|
||||
|
||||
- **GIVEN** 退款申请的 `approval_status` 不为空
|
||||
- **WHEN** 页面渲染该记录
|
||||
- **THEN** 页面 MUST NOT 显示“补发审批”操作
|
||||
|
||||
#### Scenario: Hide resend action without permission
|
||||
|
||||
- **GIVEN** 当前账号不拥有 `refund:trigger_approval` 权限
|
||||
- **WHEN** 页面渲染退款记录
|
||||
- **THEN** 页面 MUST NOT 显示“补发审批”操作
|
||||
|
||||
#### Scenario: Resend approval succeeds
|
||||
|
||||
- **GIVEN** 用户对符合条件的退款申请点击“补发审批”
|
||||
- **WHEN** 接口返回 `code=0`
|
||||
- **THEN** 页面 MUST 显示成功提示并刷新列表
|
||||
- **AND** 刷新后的记录 MUST 展示接口返回的最新审批状态
|
||||
|
||||
#### Scenario: Resend approval fails
|
||||
|
||||
- **GIVEN** 接口返回非零 `code` 或请求失败
|
||||
- **WHEN** 用户触发“补发审批”
|
||||
- **THEN** 页面 MUST 展示后端返回的错误信息
|
||||
- **AND** 页面 MUST NOT 将记录标记为已补发审批
|
||||
23
openspec/changes/add-historical-approval-resend/tasks.md
Normal file
23
openspec/changes/add-historical-approval-resend/tasks.md
Normal file
@@ -0,0 +1,23 @@
|
||||
## 1. 类型与 API 契约
|
||||
|
||||
- [x] 1.1 在 `src/api/modules/agentRecharge.ts` 新增 `triggerApproval(id)` 方法
|
||||
- [x] 1.2 在 `src/api/modules/refund.ts` 新增 `triggerApproval(id)` 方法
|
||||
- [x] 1.3 确认 `AgentRecharge` 和 `Refund` 类型包含 `approval_provider`、`approval_source`、`approval_instance_id`、`approval_status`、`approval_status_name`
|
||||
|
||||
## 2. 代理充值补发审批入口
|
||||
|
||||
- [x] 2.1 在 `agentRechargeActions.ts` 增加“补发审批”动作及资格判断
|
||||
- [x] 2.2 在代理充值列表接入权限 `agent_recharge:trigger_approval` 与成功/失败处理
|
||||
- [x] 2.3 补发成功后刷新列表并展示最新审批状态
|
||||
|
||||
## 3. 退款补发审批入口
|
||||
|
||||
- [x] 3.1 在退款列表 `getActions` 增加“补发审批”动作及资格判断
|
||||
- [x] 3.2 接入权限 `refund:trigger_approval` 与成功/失败处理
|
||||
- [x] 3.3 补发成功后刷新列表并展示最新审批状态
|
||||
|
||||
## 4. 校验与验证
|
||||
|
||||
- [x] 4.1 运行 `openspec validate add-historical-approval-resend --strict`
|
||||
- [x] 4.2 运行 ESLint、类型检查并修复
|
||||
- [ ] 4.3 手工验证有/无审批实例、线上/线下充值、权限开关等场景
|
||||
@@ -0,0 +1,17 @@
|
||||
# Change: Add purchased package names to the order list
|
||||
|
||||
## Why
|
||||
|
||||
Order list records already include their purchased line items, but operators cannot identify the purchased packages without opening the order detail. Showing these names next to the order number makes multi-package orders immediately understandable.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Place the existing `资产标识符` column immediately after `订单编号`, followed by a `购买套餐名称` column in the order list.
|
||||
- Render `items[].package_name` in the API-returned order, joining multiple item names with the Chinese enumeration separator `、`.
|
||||
- Show a placeholder when an order has no returned items or no usable package name.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `order-list-purchased-package-names`
|
||||
- Affected code: `src/views/order-management/order-list/index.vue`
|
||||
- Dependencies: `GET /api/admin/orders` returns `items` with each item's `package_name`.
|
||||
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Order list purchased package name display
|
||||
|
||||
The order list SHALL display `资产标识符` immediately after `订单编号`, followed by a `购买套餐名称` column. The package-name column MUST render package names from the current row's `items` in their API-returned order and MUST join multiple names with `、`.
|
||||
|
||||
#### Scenario: Order contains multiple purchased packages
|
||||
|
||||
- **GIVEN** an order response whose `items` contains package names `正式套餐` and `加油包33`
|
||||
- **WHEN** the order is rendered in the order list
|
||||
- **THEN** `资产标识符` MUST be positioned between `订单编号` and `购买套餐名称`
|
||||
- **AND** the `购买套餐名称` column MUST display `正式套餐、加油包33`
|
||||
- **AND** the order-number column MUST retain its existing navigation behavior
|
||||
|
||||
#### Scenario: Order has no available package names
|
||||
|
||||
- **GIVEN** an order response with `items=null`, an empty item list, or item records without usable names
|
||||
- **WHEN** the order is rendered in the order list
|
||||
- **THEN** the `购买套餐名称` column MUST display the standard placeholder
|
||||
@@ -0,0 +1,10 @@
|
||||
## 1. Implementation
|
||||
|
||||
- [x] 1.1 Place `资产标识符` after order number and add the purchased-package-name column immediately after it in the order-list column chooser.
|
||||
- [x] 1.2 Render package names from `Order.items` in API-returned order, separating multiple values with `、`.
|
||||
- [x] 1.3 Render a placeholder for null, empty, or unnamed order items without altering the order-number link behavior.
|
||||
|
||||
## 2. Verification
|
||||
|
||||
- [x] 2.1 Verify a single item renders its package name and multiple items render `套餐A、套餐B`.
|
||||
- [x] 2.2 Run targeted format, type, and lint checks.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Change: 收紧审计接口的主体访问边界
|
||||
|
||||
## Why
|
||||
|
||||
平台 `/api/admin/audit/*` 接口仅允许超级管理员和平台账号访问。当前部分业务入口未传递当前账号类型,可能让代理或企业用户构造平台资源时间线目标;平台审计页面和调查抽屉也缺少统一的前端请求拦截。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 平台审计中心、平台资源时间线、资金链路、请求链路、风险和 Integration 等 `/api/admin/audit/*` 调用只允许超级管理员或平台账号发起。
|
||||
- 代理账号仅通过代理主体活动接口查看其支持资源;企业账号仅通过企业主体活动接口查看卡和设备资源。
|
||||
- 业务入口基于当前登录账号类型选择对应权限与调查目标,账号、店铺等不受主体活动支持的资源不向代理或企业显示平台审计入口。
|
||||
- 在共享调查加载层增加防御性校验,越权目标不发起网络请求并提示不可用。
|
||||
- 保持审计能力严格只读:所有相关前端 API 保持 GET,不新增修改、删除、导出、恢复、重试、补偿或风险处置操作。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `audit-chain-frontend-integration`
|
||||
- Affected code: `src/components/business/audit/`, `src/utils/business/auditNavigation.ts`, audit routes/views, and business pages with audit-entry buttons including account, shop, asset, order, refund and wallet views.
|
||||
@@ -0,0 +1,26 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 审计接口按当前主体隔离
|
||||
|
||||
前端 SHALL 仅允许超级管理员和平台账号调用 `/api/admin/audit/*` 平台审计接口。代理账号 MUST 仅调用其支持资源的代理主体活动接口;企业账号 MUST 仅调用卡和设备资源的企业主体活动接口。共享调查加载层 MUST 在请求前拒绝不匹配当前主体的调查目标。
|
||||
|
||||
#### Scenario: 代理或企业用户访问业务审计入口
|
||||
|
||||
- **WHEN** 代理或企业用户打开业务列表、详情或资源信息
|
||||
- **THEN** 前端仅显示当前主体支持的活动入口
|
||||
- **AND** 前端不请求任何 `/api/admin/audit/*` 接口
|
||||
|
||||
#### Scenario: 平台用户访问审计调查
|
||||
|
||||
- **WHEN** 超级管理员或平台账号打开平台审计、资源时间线、资金链路、请求链路、风险或 Integration 调查
|
||||
- **THEN** 前端使用对应的 `/api/admin/audit/*` GET 接口
|
||||
|
||||
### Requirement: 审计调查仅提供只读能力
|
||||
|
||||
审计调查前端 SHALL 仅使用 GET 请求读取平台审计或主体活动数据,且 MUST NOT 提供修改、删除、导出、恢复、重试、补偿或风险处置操作。
|
||||
|
||||
#### Scenario: 用户查看审计或主体活动记录
|
||||
|
||||
- **WHEN** 用户查询任一审计或主体活动视图
|
||||
- **THEN** 页面只展示只读数据和受控跳转
|
||||
- **AND** 页面不展示或调用任何写入、导出、恢复、重试、补偿或风险处置操作
|
||||
@@ -0,0 +1,16 @@
|
||||
## 1. Shared Access Rules
|
||||
|
||||
- [x] 1.1 Add shared current-user access checks for platform audit and subject activity APIs.
|
||||
- [x] 1.2 Guard the shared investigation loader so it does not request a platform audit API for agent or enterprise users.
|
||||
|
||||
## 2. Entry Points and Pages
|
||||
|
||||
- [x] 2.1 Update business audit entry points to pass current user type and use only supported subject activity targets for agents and enterprises.
|
||||
- [x] 2.2 Hide or block platform audit routes and actions for agent and enterprise users.
|
||||
- [x] 2.3 Keep finance, risk, Integration and link-timeline entries platform-only.
|
||||
|
||||
## 3. Read-only Verification
|
||||
|
||||
- [x] 3.1 Verify all audit and subject-activity methods used by the UI are GET-only.
|
||||
- [x] 3.2 Verify platform, agent and enterprise users cannot trigger the wrong API family.
|
||||
- [x] 3.3 Run lint and type checks for changed files.
|
||||
16
openspec/changes/enrich-actor-timeline-detail/proposal.md
Normal file
16
openspec/changes/enrich-actor-timeline-detail/proposal.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Change: 补全操作者行为时间线事件信息
|
||||
|
||||
## Why
|
||||
|
||||
操作者行为时间线接口已返回完整审计事件字段,但当前调查抽屉仅展示动作、结果、摘要、操作者名称和来源,无法支持对操作者所属组织、业务范围、请求上下文、批次结果、失败原因和关联资源的有效追溯。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在操作者行为时间线的每个事件节点展示接口返回的分类、风险、操作者身份与组织快照、业务范围、请求摘要、批次统计和错误摘要。
|
||||
- 展示关联资源的名称、类型、关系、业务角色和主体摘要;将资源快照与前后变更数据放入按需展开的只读区域。
|
||||
- 对缺失或空字段使用明确的空态,不从名称、时间或相邻事件推断数据;保留分页、排序与只读边界。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `audit-chain-frontend-integration`
|
||||
- Affected code: `src/components/business/audit/AuditInvestigationDrawer.vue`
|
||||
@@ -0,0 +1,29 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 操作者行为时间线完整事件展示
|
||||
|
||||
前端 SHALL 在操作者行为时间线中展示接口返回的事件分类、动作、结果、风险、摘要、操作者身份及所属组织快照、业务范围、批次统计、错误摘要和关联资源;分类、来源、操作者和业务范围 MUST 在同一行展示。请求摘要与扩展元数据 MUST 仅向超级管理员展示。资源身份快照、主体安全数据及变更前后数据 MUST 以按需展开的只读区域展示。
|
||||
|
||||
#### Scenario: 查看包含完整审计字段的操作者事件
|
||||
|
||||
- **WHEN** `/api/admin/audit/actors/{kind}/{id}/events` 返回包含操作者、范围、请求、批次、错误和资源字段的事件
|
||||
- **THEN** 前端在对应时间线节点中展示这些字段的业务可读值
|
||||
- **AND** 将资源快照与变更数据放入可折叠的只读区域
|
||||
|
||||
#### Scenario: 非超级管理员查看事件
|
||||
|
||||
- **WHEN** 非超级管理员打开操作者行为时间线
|
||||
- **THEN** 前端不展示请求上下文和扩展元数据
|
||||
- **AND** 其余允许展示的事件字段保持只读
|
||||
|
||||
#### Scenario: 字段缺失或资源为空
|
||||
|
||||
- **WHEN** 事件中可选字段为空或没有关联资源
|
||||
- **THEN** 前端显示明确空态或隐藏对应区域
|
||||
- **AND** 不从名称、时间或其他事件推断或补造数据
|
||||
|
||||
#### Scenario: 保持只读与分页语义
|
||||
|
||||
- **WHEN** 用户查看或翻页操作者行为时间线
|
||||
- **THEN** 前端继续使用文档规定的分页与排序结果
|
||||
- **AND** 不提供修改、删除、导出、恢复、重试、补偿或风险处置操作
|
||||
10
openspec/changes/enrich-actor-timeline-detail/tasks.md
Normal file
10
openspec/changes/enrich-actor-timeline-detail/tasks.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## 1. Implementation
|
||||
|
||||
- [x] 1.1 为操作者行为时间线事件节点补充接口已返回的事件、操作者、范围、请求、批次和错误字段。
|
||||
- [x] 1.2 以可折叠只读区域展示关联资源与快照/变更数据,并处理空值和无资源场景。
|
||||
- [x] 1.3 保持资源、请求和关联标识只读展示,不新增跳转或写操作。
|
||||
|
||||
## 2. Verification
|
||||
|
||||
- [x] 2.1 验证接口返回字段均按文档语义显示且不改变分页和排序。
|
||||
- [x] 2.2 运行格式化、类型检查和 ESLint。
|
||||
@@ -0,0 +1,17 @@
|
||||
# Change: 补全业务关联链路节点展示
|
||||
|
||||
## Why
|
||||
|
||||
业务关联链路弹窗当前每个节点仅显示标题、结果、摘要和事实来源,无法清晰查看节点编码、关联范围和资源引用。操作者行为时间线已采用结构化、按需展开的只读展示,业务关联链路需要使用相同的视觉层级,但不能假设接口返回了操作者事件专有字段。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 基于 `AuditLinkTimelineNode` 的实际返回字段,使用与操作者行为时间线一致的卡片、标签和基础信息单行布局展示业务关联节点。
|
||||
- 仅展示节点实际返回的事实来源和稳定编码;关联链路、父审计事件、请求和资源内部 ID 不在界面显示,数据完整性信息仅超级管理员可见。
|
||||
- 当节点返回资源引用时,以折叠的只读区域展示资源名称、类型和业务 Key;不推断资源快照、操作者、风险或变更数据。
|
||||
- 保持业务关联链路全量查询、排序、只读和无额外跳转的现有语义。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `audit-chain-frontend-integration`
|
||||
- Affected code: `src/components/business/audit/AuditInvestigationDrawer.vue`
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 业务关联链路结构化节点展示
|
||||
|
||||
前端 SHALL 在业务关联链路中以与操作者行为时间线一致的层级展示每个 `AuditLinkTimelineNode`:节点标题、结果、事实来源和稳定编码 MUST 使用接口实际返回值;基础信息 MUST 在同一行展示。关联链路、父审计事件、请求和资源内部 ID MUST 不在界面显示;数据完整性信息 MUST 仅向超级管理员展示。资源引用 MUST 按需在折叠的只读区域展示。
|
||||
|
||||
#### Scenario: 查看包含资源引用的关联链路节点
|
||||
|
||||
- **WHEN** 业务关联链路返回含资源引用和关联标识的节点
|
||||
- **THEN** 前端展示节点基础信息并提供只读、可折叠的资源引用区域
|
||||
- **AND** 不推断操作者、分类、风险、资源快照或变更数据
|
||||
|
||||
#### Scenario: 非超级管理员查看关联链路
|
||||
|
||||
- **WHEN** 非超级管理员打开业务关联链路弹窗
|
||||
- **THEN** 前端不展示请求标识和数据完整性信息
|
||||
- **AND** 继续展示其余允许的节点字段
|
||||
|
||||
#### Scenario: 保持只读链路语义
|
||||
|
||||
- **WHEN** 用户查看业务关联链路
|
||||
- **THEN** 前端不提供修改、删除、导出、恢复、重试、补偿或风险处置操作
|
||||
10
openspec/changes/enrich-correlation-timeline-detail/tasks.md
Normal file
10
openspec/changes/enrich-correlation-timeline-detail/tasks.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## 1. Implementation
|
||||
|
||||
- [x] 1.1 为业务关联链路节点增加与操作者时间线一致的基础信息单行布局。
|
||||
- [x] 1.2 隐藏关联链路中的所有 ID,并按超级管理员边界展示数据完整性信息。
|
||||
- [x] 1.3 对接口返回的资源引用增加折叠的只读展示,并处理空字段。
|
||||
|
||||
## 2. Verification
|
||||
|
||||
- [x] 2.1 验证只使用 `AuditLinkTimelineNode` 已返回字段,不补造操作者事件字段。
|
||||
- [x] 2.2 运行格式化、ESLint、类型检查和 OpenSpec 严格校验。
|
||||
17
openspec/changes/enrich-finance-timeline-detail/proposal.md
Normal file
17
openspec/changes/enrich-finance-timeline-detail/proposal.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Change: 补全财务审计时间线节点展示
|
||||
|
||||
## Why
|
||||
|
||||
资金调查时间线接口已返回金额、前后余额、金额权威来源、钱包引用和安全结构化事实,但当前弹窗只展示标题、结果、金额、编码和来源,无法支持资金事实核对。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 按“查询资金调查时间线”中的 `AuditFinanceTimelineNode` 响应结构,将财务审计节点改为与既有时间线一致的结构化卡片与折叠信息区。
|
||||
- 显示金额、变更前后余额、币种、事实来源、稳定编码和钱包类型;金额均按分转换为展示金额。
|
||||
- 金额权威来源与安全结构化事实使用按需展开的只读区域;不显示内部节点、店铺或钱包 ID,不新增跳转或写操作。
|
||||
- 保留现有稳定条件查询、分页、排序和权威金额字段语义。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `audit-chain-frontend-integration`
|
||||
- Affected code: `src/components/business/audit/AuditInvestigationDrawer.vue`
|
||||
@@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 财务审计时间线完整资金事实展示
|
||||
|
||||
前端 SHALL 在财务审计时间线中按 `AuditFinanceTimelineNode` 展示标题、结果、金额、变更前后余额、币种、事实来源、稳定编码和钱包类型。金额与余额 MUST 按分转换为展示金额。金额权威来源和安全结构化事实 MUST 使用按需展开的只读区域;内部节点、店铺和钱包 ID MUST 不在界面显示。
|
||||
|
||||
#### Scenario: 查看带金额权威信息的资金节点
|
||||
|
||||
- **WHEN** 资金节点返回金额、余额、钱包和 `amount_authority`
|
||||
- **THEN** 前端展示格式化后的金额与余额,并在折叠区域展示权威来源说明
|
||||
- **AND** 不将非权威字段当作权威金额
|
||||
|
||||
#### Scenario: 查看安全结构化事实
|
||||
|
||||
- **WHEN** 资金节点返回非空 `facts`
|
||||
- **THEN** 前端提供可折叠的只读 JSON 展示
|
||||
- **AND** 不显示内部 ID 或提供写操作
|
||||
|
||||
#### Scenario: 金额字段为空
|
||||
|
||||
- **WHEN** 资金节点不承载金额或余额
|
||||
- **THEN** 前端隐藏相应金额项或显示中性空态
|
||||
- **AND** 保持分页和排序结果不变
|
||||
10
openspec/changes/enrich-finance-timeline-detail/tasks.md
Normal file
10
openspec/changes/enrich-finance-timeline-detail/tasks.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## 1. Implementation
|
||||
|
||||
- [x] 1.1 按 `AuditFinanceTimelineNode` 补充金额、余额、币种、来源、编码和钱包类型展示。
|
||||
- [x] 1.2 增加金额权威来源和安全结构化事实的折叠只读区域。
|
||||
- [x] 1.3 隐藏内部 ID,处理无金额、无钱包和空结构化字段场景。
|
||||
|
||||
## 2. Verification
|
||||
|
||||
- [x] 2.1 验证金额单位按分转换,并保持 `amount_authority.authoritative` 语义。
|
||||
- [x] 2.2 运行格式化、ESLint、类型检查和 OpenSpec 严格校验。
|
||||
17
openspec/changes/enrich-request-timeline-detail/proposal.md
Normal file
17
openspec/changes/enrich-request-timeline-detail/proposal.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Change: 补全请求链路节点展示
|
||||
|
||||
## Why
|
||||
|
||||
请求链路弹窗当前仅呈现标题、结果、摘要和事实来源,未充分利用接口返回的稳定编码、资源引用和关联完整性信息,且与已调整的操作者、业务关联链路节点样式不一致。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 基于 `查询请求关联时间线_简化版.md` 的 `AuditLinkTimelineNode`,将请求链路节点改为与既有时间线一致的结构化卡片与单行基础信息布局。
|
||||
- 节点仅展示标题、结果、事实来源和稳定编码;不显示请求、业务关联、父审计事件、节点或资源内部 ID。
|
||||
- 资源引用以折叠只读区域展示名称、类型和业务 Key;关联完整性信息仅超级管理员可见。
|
||||
- 保持接口全量排序和只读语义,不新增跳转或写操作。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `audit-chain-frontend-integration`
|
||||
- Affected code: `src/components/business/audit/AuditInvestigationDrawer.vue`
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 请求链路结构化节点展示
|
||||
|
||||
前端 SHALL 在请求链路中以与操作者和业务关联链路一致的层级展示每个 `AuditLinkTimelineNode`:节点标题、结果、事实来源和稳定编码 MUST 使用接口实际返回值,并在同一行展示基础信息。请求、业务关联、父审计事件、节点和资源内部 ID MUST 不在界面显示。资源引用 MUST 以按需展开的只读区域展示;数据完整性信息 MUST 仅向超级管理员展示。
|
||||
|
||||
#### Scenario: 查看含资源引用的请求链路节点
|
||||
|
||||
- **WHEN** 请求链路返回含资源引用和关联完整性信息的节点
|
||||
- **THEN** 前端展示节点基础信息和可折叠的资源引用区域
|
||||
- **AND** 不展示接口返回的任何 ID
|
||||
|
||||
#### Scenario: 非超级管理员查看请求链路
|
||||
|
||||
- **WHEN** 非超级管理员打开请求链路弹窗
|
||||
- **THEN** 前端不展示数据完整性信息
|
||||
- **AND** 继续展示其余允许的节点字段
|
||||
|
||||
#### Scenario: 保持请求链路只读语义
|
||||
|
||||
- **WHEN** 用户查看请求链路
|
||||
- **THEN** 前端不提供修改、删除、导出、恢复、重试、补偿或风险处置操作
|
||||
10
openspec/changes/enrich-request-timeline-detail/tasks.md
Normal file
10
openspec/changes/enrich-request-timeline-detail/tasks.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## 1. Implementation
|
||||
|
||||
- [x] 1.1 为请求链路节点增加与既有时间线一致的基础信息布局。
|
||||
- [x] 1.2 隐藏所有 ID,仅向超级管理员展示数据完整性信息。
|
||||
- [x] 1.3 对请求链路返回的资源引用增加折叠只读展示并处理空字段。
|
||||
|
||||
## 2. Verification
|
||||
|
||||
- [x] 2.1 验证仅使用简化版文档已定义的 `AuditLinkTimelineNode` 字段。
|
||||
- [x] 2.2 运行格式化、ESLint、类型检查和 OpenSpec 严格校验。
|
||||
17
openspec/changes/enrich-resource-timeline-detail/proposal.md
Normal file
17
openspec/changes/enrich-resource-timeline-detail/proposal.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Change: 补全通用资源时间线事件展示
|
||||
|
||||
## Why
|
||||
|
||||
通用资源时间线接口返回完整 `AuditEventPage` / `AuditEventView`,但当前弹窗仅显示动作、结果、摘要、操作者和来源,未使用接口返回的分类、风险、操作者组织、业务范围、批次、错误、资源快照与变更数据。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 依据“查询通用资源时间线”响应结构,在资源审计时间线中复用操作者时间线的完整事件节点布局。
|
||||
- 展示分类、来源、操作者、业务范围、风险、批次、错误及关联资源;基础信息同一行,资源 JSON 按需折叠。
|
||||
- 请求上下文与扩展元数据仅超级管理员可见;不新增请求筛选、跳转或任何写操作。
|
||||
- 保持接口现有分页、排序和“缺少 resource_id 隐藏入口”的调用边界。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `audit-chain-frontend-integration`
|
||||
- Affected code: `src/components/business/audit/AuditInvestigationDrawer.vue`
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 通用资源时间线完整事件展示
|
||||
|
||||
前端 SHALL 按通用资源时间线返回的 `AuditEventPage` 展示完整 `AuditEventView` 节点:分类、来源、操作者、业务范围 MUST 在同一行展示;风险、批次统计、错误信息及关联资源 MUST 使用接口返回值。资源身份快照、主体安全数据及变更前后数据 MUST 以按需展开的只读区域展示。请求上下文与扩展元数据 MUST 仅向超级管理员展示。
|
||||
|
||||
#### Scenario: 查看包含资源快照的资源时间线事件
|
||||
|
||||
- **WHEN** 资源时间线返回包含批次、错误和关联资源快照的事件
|
||||
- **THEN** 前端在对应节点展示完整事件字段和折叠的资源数据
|
||||
- **AND** 不推断缺失字段或增加写操作
|
||||
|
||||
#### Scenario: 非超级管理员查看资源时间线
|
||||
|
||||
- **WHEN** 非超级管理员打开资源审计时间线
|
||||
- **THEN** 前端不展示请求上下文和扩展元数据
|
||||
- **AND** 继续展示其余允许的事件字段
|
||||
|
||||
#### Scenario: 保持资源时间线分页语义
|
||||
|
||||
- **WHEN** 用户翻页资源审计时间线
|
||||
- **THEN** 前端继续使用接口返回的页码、每页数量和稳定排序结果
|
||||
10
openspec/changes/enrich-resource-timeline-detail/tasks.md
Normal file
10
openspec/changes/enrich-resource-timeline-detail/tasks.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## 1. Implementation
|
||||
|
||||
- [x] 1.1 将资源时间线接入完整 `AuditEventView` 节点展示。
|
||||
- [x] 1.2 复用分类、来源、操作者、业务范围单行布局以及批次、错误和资源快照折叠区。
|
||||
- [x] 1.3 确保请求上下文和扩展元数据仅超级管理员可见。
|
||||
|
||||
## 2. Verification
|
||||
|
||||
- [x] 2.1 验证接口分页、排序、空值和资源 ID 入口边界未改变。
|
||||
- [x] 2.2 运行格式化、ESLint、类型检查和 OpenSpec 严格校验。
|
||||
@@ -0,0 +1,18 @@
|
||||
# Change: 移除旧资产操作审计日志入口
|
||||
|
||||
## Why
|
||||
|
||||
IoT 卡管理、设备管理和资产信息页仍展示旧的资产操作审计日志入口与内容,与当前审计调查能力重复,且不再需要向用户提供该旧日志交互。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 移除 IoT 卡管理列表的“操作审计日志”操作项及其抽屉弹窗。
|
||||
- 移除设备管理列表的“操作审计日志”操作项及其抽屉弹窗。
|
||||
- 移除资产信息页内嵌的操作审计日志卡片。
|
||||
- 清理上述页面对旧日志组件的引用;全局无其他前端调用时,删除旧日志组件、前端 API 方法及专用类型;仍有调用时保留共享实现。
|
||||
- 不改变后端历史日志数据或接口。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `asset-audit-log-navigation`
|
||||
- Affected code: `src/views/asset-management/iot-card-management/index.vue`, `src/views/asset-management/device-list/index.vue`, `src/views/asset-management/asset-information/index.vue`, and, only if unused, `src/components/business/OperationLogsDialog.vue`, `src/views/asset-management/asset-information/components/OperationLogsCard.vue`, `src/api/modules/asset.ts`, `src/types/api/asset.ts`
|
||||
@@ -0,0 +1,11 @@
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: 旧资产操作审计日志入口
|
||||
|
||||
**Reason**: 旧资产操作审计日志交互已由当前审计调查能力替代,页面不再提供重复入口。 **Migration**: 需要审计调查的用户通过当前审计中心和资源审计时间线访问记录。
|
||||
|
||||
#### Scenario: 查看 IoT 卡、设备或资产信息
|
||||
|
||||
- **WHEN** 用户打开 IoT 卡管理、设备管理或资产信息页面
|
||||
- **THEN** 页面不显示旧的“操作审计日志”入口、弹窗或内嵌日志卡片
|
||||
- **AND** 前端不因这些页面加载或操作而请求旧资产操作日志接口
|
||||
10
openspec/changes/remove-legacy-asset-operation-logs/tasks.md
Normal file
10
openspec/changes/remove-legacy-asset-operation-logs/tasks.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## 1. Remove User Interfaces
|
||||
|
||||
- [x] 1.1 Remove the IoT card management operation-log action, state and dialog.
|
||||
- [x] 1.2 Remove the device management operation-log action, state and dialog.
|
||||
- [x] 1.3 Remove the embedded operation-log card from asset information.
|
||||
|
||||
## 2. Cleanup and Verification
|
||||
|
||||
- [x] 2.1 Search remaining consumers of the legacy log component, front-end API method and dedicated types; delete only implementations with no consumers.
|
||||
- [x] 2.2 Run lint and type checks for changed files.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Change: 统一资产套餐流量展示文案
|
||||
|
||||
## Why
|
||||
|
||||
资产信息页的当前生效套餐和套餐列表对同一虚流量数据使用了不一致且容易造成误解的展示文案。需要按业务术语统一为更通用的流量用量表述。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 将“当前生效套餐”中虚流量统计区域的标题从“虚流量使用”调整为“流量用量”。
|
||||
- 将“套餐列表”流量字段中虚流量已用值的标签从“已使用虚流量”调整为“已使用流量”。
|
||||
- 保持现有流量字段、数值计算、进度条、可见性权限和接口契约不变。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `asset-package-traffic-display`
|
||||
- Affected code:
|
||||
- `src/views/asset-management/asset-information/components/CurrentPackageCard.vue`
|
||||
- `src/views/asset-management/asset-information/components/PackageListCard.vue`
|
||||
- Dependencies: `update-asset-package-traffic-threshold-layout` 中定义的套餐流量展示能力
|
||||
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Asset Package Traffic Terminology
|
||||
|
||||
The asset information page SHALL use the approved traffic terminology while retaining the existing virtual traffic data source and display behavior.
|
||||
|
||||
#### Scenario: Current package displays the traffic usage heading
|
||||
|
||||
- **GIVEN** 当前生效套餐的虚流量统计区域可见
|
||||
- **WHEN** 页面渲染该统计区域
|
||||
- **THEN** 系统 MUST 将区域标题显示为“流量用量”
|
||||
- **AND** 系统 MUST NOT 显示“虚流量使用”作为该区域标题
|
||||
|
||||
#### Scenario: Package list displays the used traffic label
|
||||
|
||||
- **GIVEN** 套餐列表记录的虚流量统计区域可见
|
||||
- **WHEN** 页面渲染该记录的已用流量值
|
||||
- **THEN** 系统 MUST 将字段标签显示为“已使用流量”
|
||||
- **AND** 系统 MUST NOT 显示“已使用虚流量”作为该字段标签
|
||||
@@ -0,0 +1,5 @@
|
||||
## 1. Implementation
|
||||
|
||||
- [x] 1.1 将当前生效套餐虚流量统计区域标题更新为“流量用量”。
|
||||
- [x] 1.2 将套餐列表虚流量已用字段标签更新为“已使用流量”。
|
||||
- [x] 1.3 验证文案变更不影响现有流量数值、进度条和权限可见性。
|
||||
@@ -0,0 +1,27 @@
|
||||
# Change: 调整资产套餐真虚流量展示布局
|
||||
|
||||
## Why
|
||||
|
||||
资产信息页“当前生效套餐”和“套餐列表”目前将虚流量停机阈值及其断点标记放在虚流量使用区块,而真流量剩余未直接关联停机阈值。业务需要让停机阈值和断点与真流量剩余在同一区域展示,同时使虚流量区块完整呈现其基于真流量总量计算的总量和剩余。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在“当前生效套餐”和“套餐列表”的管理员/平台双流量视图中,将“虚流量停机阈值”展示项移至“真流量剩余”之后,继续显示现有的 `virtual_total_mb` 值。
|
||||
- 将虚流量停机阈值对应的断点标记从虚流量进度条移至真流量进度条;断点位置继续按 `virtual_total_mb / real_total_mb` 计算。
|
||||
- 在“虚流量使用”区块的“已使用”后新增:
|
||||
- “总量”:显示真流量总量 `real_total_mb`。
|
||||
- “剩余”:按 `real_total_mb - virtual_used_mb` 计算。
|
||||
- 保持真流量和虚流量进度条的现有计算口径、字段来源、权限控制及非管理员单一流量展示不变。
|
||||
- 不修改后端接口、响应字段或数据模型。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs:
|
||||
- `asset-package-traffic-display`
|
||||
- `asset-package-traffic-permissions`
|
||||
- Affected code:
|
||||
- `src/views/asset-management/asset-information/components/CurrentPackageCard.vue`
|
||||
- `src/views/asset-management/asset-information/components/PackageListCard.vue`
|
||||
- Dependencies:
|
||||
- 继续使用现有套餐流量字段:`real_total_mb`、`real_used_mb`、`virtual_used_mb`、`virtual_total_mb`
|
||||
- 继续使用权限 `asset_info:view_current_package_real_usage` 和 `asset_info:view_current_package_virtual_usage`
|
||||
@@ -0,0 +1,55 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Virtual Traffic Threshold Aligns With Real Traffic Remaining
|
||||
|
||||
For super admins and platform users, the asset information page SHALL display the virtual traffic stop threshold immediately after real traffic remaining in both the `当前生效套餐` card and each applicable `套餐列表` row.
|
||||
|
||||
The displayed stop-threshold value MUST continue to use `virtual_total_mb`. The corresponding breakpoint marker MUST render on the real traffic progress bar and MUST continue to calculate its position as `virtual_total_mb / real_total_mb`.
|
||||
|
||||
#### Scenario: Current package shows threshold and breakpoint with real traffic
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 为 `1` 或 `2`
|
||||
- **AND** 当前套餐可展示真流量和虚流量使用信息
|
||||
- **WHEN** 页面渲染“当前生效套餐”的流量信息
|
||||
- **THEN** 系统 MUST 在真流量剩余后展示虚流量停机阈值
|
||||
- **AND** 停机阈值 MUST 显示 `virtual_total_mb` 的现有格式化值
|
||||
- **AND** 系统 MUST 将停机阈值断点标记渲染在真流量进度条
|
||||
- **AND** 断点位置 MUST 按 `virtual_total_mb / real_total_mb` 计算
|
||||
- **AND** 系统 MUST NOT 在虚流量进度条渲染该断点标记
|
||||
|
||||
#### Scenario: Package list shows threshold and breakpoint with real traffic
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 为 `1` 或 `2`
|
||||
- **AND** 套餐列表中的记录可展示真流量和虚流量使用信息
|
||||
- **WHEN** 页面渲染该记录的流量信息
|
||||
- **THEN** 系统 MUST 在真流量剩余后展示虚流量停机阈值
|
||||
- **AND** 停机阈值 MUST 显示该记录的 `virtual_total_mb` 的现有格式化值
|
||||
- **AND** 系统 MUST 将该记录的停机阈值断点标记渲染在真流量进度条
|
||||
- **AND** 断点位置 MUST 按该记录的 `virtual_total_mb / real_total_mb` 计算
|
||||
- **AND** 系统 MUST NOT 在虚流量进度条渲染该断点标记
|
||||
|
||||
### Requirement: Virtual Traffic Usage Displays Real Total And Remaining
|
||||
|
||||
For super admins and platform users, the `虚流量使用` section on the asset information page SHALL display virtual traffic used, real traffic total, and remaining virtual traffic capacity in that order in both the `当前生效套餐` card and each applicable `套餐列表` row.
|
||||
|
||||
The total MUST use `real_total_mb`. The remaining value MUST be calculated as `real_total_mb - virtual_used_mb`.
|
||||
|
||||
#### Scenario: Current package virtual traffic shows total and remaining
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 为 `1` 或 `2`
|
||||
- **AND** 当前套餐可展示虚流量使用信息
|
||||
- **WHEN** 页面渲染“当前生效套餐”的虚流量使用区块
|
||||
- **THEN** 系统 MUST 在虚流量已使用后展示总量
|
||||
- **AND** 总量 MUST 显示 `real_total_mb` 的现有格式化值
|
||||
- **AND** 系统 MUST 在总量后展示剩余
|
||||
- **AND** 剩余 MUST 显示 `real_total_mb - virtual_used_mb` 的现有格式化值
|
||||
|
||||
#### Scenario: Package list virtual traffic shows total and remaining
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 为 `1` 或 `2`
|
||||
- **AND** 套餐列表中的记录可展示虚流量使用信息
|
||||
- **WHEN** 页面渲染该记录的虚流量使用区块
|
||||
- **THEN** 系统 MUST 在虚流量已使用后展示总量
|
||||
- **AND** 总量 MUST 显示该记录的 `real_total_mb` 的现有格式化值
|
||||
- **AND** 系统 MUST 在总量后展示剩余
|
||||
- **AND** 剩余 MUST 显示 `real_total_mb - virtual_used_mb` 的现有格式化值
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Traffic Threshold And Virtual Statistics Follow Existing Usage Permissions
|
||||
|
||||
The asset information page SHALL apply the existing real and virtual traffic usage permissions to the relocated virtual traffic stop threshold, breakpoint marker, and added virtual traffic statistics.
|
||||
|
||||
#### Scenario: Hidden real traffic usage also hides relocated threshold and breakpoint
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 为 `1` 或 `2`
|
||||
- **AND** 账号缺少 `asset_info:view_current_package_real_usage` 权限
|
||||
- **WHEN** 页面渲染“当前生效套餐”或“套餐列表”的套餐流量信息
|
||||
- **THEN** 系统 MUST NOT 显示真流量统计
|
||||
- **AND** 系统 MUST NOT 显示位于真流量区域的虚流量停机阈值
|
||||
- **AND** 系统 MUST NOT 显示位于真流量进度条的停机阈值断点标记
|
||||
|
||||
#### Scenario: Hidden virtual traffic usage also hides added virtual statistics
|
||||
|
||||
- **GIVEN** 当前登录账号的 `user_type` 为 `1` 或 `2`
|
||||
- **AND** 账号缺少 `asset_info:view_current_package_virtual_usage` 权限
|
||||
- **WHEN** 页面渲染“当前生效套餐”或“套餐列表”的套餐流量信息
|
||||
- **THEN** 系统 MUST NOT 显示虚流量使用区块
|
||||
- **AND** 系统 MUST NOT 显示虚流量区块中的已使用、总量或剩余统计
|
||||
@@ -0,0 +1,16 @@
|
||||
## 1. Implementation
|
||||
|
||||
- [x] 1.1 调整“当前生效套餐”真流量统计项,在真流量剩余后展示虚流量停机阈值,并保持 `virtual_total_mb` 的现有值格式化规则。
|
||||
- [x] 1.2 将“当前生效套餐”的停机阈值断点标记移至真流量进度条,继续按 `virtual_total_mb / real_total_mb` 定位。
|
||||
- [x] 1.3 调整“当前生效套餐”虚流量统计项,在已使用后展示真流量总量和按 `real_total_mb - virtual_used_mb` 计算的剩余量。
|
||||
- [x] 1.4 对套餐列表的每条管理员/平台套餐记录应用相同的阈值、断点和虚流量统计布局。
|
||||
- [x] 1.5 保持非管理员单一流量展示、现有权限可见性和各进度条百分比计算不变。
|
||||
|
||||
## 2. Verification
|
||||
|
||||
- [x] 2.1 验证“当前生效套餐”中停机阈值显示在真流量剩余后,且其值仍为 `virtual_total_mb`。
|
||||
- [x] 2.2 验证“当前生效套餐”中断点显示在真流量进度条,且其位置仍按 `virtual_total_mb / real_total_mb` 计算。
|
||||
- [x] 2.3 验证“当前生效套餐”虚流量使用依次显示已使用、总量(`real_total_mb`)和剩余(`real_total_mb - virtual_used_mb`)。
|
||||
- [x] 2.4 验证套餐列表的管理员/平台套餐记录具有与当前生效套餐一致的展示布局和计算结果。
|
||||
- [x] 2.5 验证真流量或虚流量查看权限缺失时,不展示该权限对应的统计项及关联阈值/断点内容。
|
||||
- [x] 2.6 验证非管理员账号继续使用现有单一流量展示,且真流量与虚流量进度百分比不变。
|
||||
@@ -32,11 +32,11 @@
|
||||
- Decision: 业务列表创建导出任务按钮继续使用来源业务权限,默认建议如下。
|
||||
- Rationale: 创建导出任务属于来源业务列表能力,不应因为拥有某个导出任务管理页面权限就获得业务列表导出入口。
|
||||
|
||||
| 入口 | 创建导出任务权限 | scene |
|
||||
| --- | --- | --- |
|
||||
| 设备管理 | `devices:export` | `device` |
|
||||
| 入口 | 创建导出任务权限 | scene |
|
||||
| ---------- | ----------------- | ---------- |
|
||||
| 设备管理 | `devices:export` | `device` |
|
||||
| IOT 卡管理 | `iot_card:export` | `iot_card` |
|
||||
| 订单列表 | `orders:export` | `order` |
|
||||
| 订单列表 | `orders:export` | `order` |
|
||||
|
||||
- Decision: 详情页可继续复用同一组件,但必须根据任务场景或来源页面使用对应场景的详情、下载、取消权限。
|
||||
- Rationale: 详情页面本身也包含下载和取消按钮,若继续使用通用权限会绕过三个列表页面的按钮隔离要求。
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
## Context
|
||||
|
||||
顶部通知抽屉已经通过通知列表接口加载最近 10 条,但当前共享状态没有保存列表分页信息。分页应复用现有通知列表接口,避免新增接口或改变通知数据契约。
|
||||
|
||||
## Decisions
|
||||
|
||||
- Decision: 分页状态由通知 store 保存,包括当前页、每页数量和总条数;列表请求返回后整体替换 `recentNotifications`。
|
||||
- Decision: 页码切换使用 `GET /api/admin/notifications?page=<page>&page_size=10`,加载期间沿用现有 loading 状态。
|
||||
- Decision: 分类切换将页码重置为 1,并重新加载列表;不使用滚动事件触发请求。
|
||||
- Decision: 分页控件放在通知列表底部,列表区域继续保持固定高度,避免抽屉整体布局跳动。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- 分类当前由抽屉对已加载页面进行过滤,分类页的总数仍由通知列表接口返回的总体 `total` 表示;本次不扩展后端分类分页契约。
|
||||
@@ -0,0 +1,24 @@
|
||||
# Change: 通知抽屉改为分页浏览
|
||||
|
||||
## Why
|
||||
|
||||
顶部通知抽屉目前只展示最近 10 条通知,用户无法通过页码查看更早的通知。通知列表应使用明确的分页操作,避免依赖下滑加载更多的交互。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 顶部通知抽屉增加页码分页控件,默认每页展示 10 条。
|
||||
- 切换页码时调用通知列表接口并替换当前列表,不追加滚动加载。
|
||||
- 切换通知分类时重置到第 1 页,并保持现有分类统计、已读和跳转行为。
|
||||
- 保留 `/api/admin/notifications` 的 `page`、`page_size` 分页参数和后端返回的 `total`。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs:
|
||||
- `notification-center`
|
||||
- Affected code:
|
||||
- `src/components/core/layouts/art-notification/index.vue`
|
||||
- `src/components/core/layouts/art-notification/style.scss`
|
||||
- `src/store/modules/notification.ts`
|
||||
- Out of scope:
|
||||
- 不改变通知接口、通知分类统计和已读接口。
|
||||
- 不接入下滑加载、无限滚动或新的实时推送机制。
|
||||
@@ -0,0 +1,22 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Global Notification Bell
|
||||
|
||||
The admin frontend SHALL provide a global notification bell in the top navigation near the settings and user avatar entries.
|
||||
|
||||
#### Scenario: Display unread count
|
||||
|
||||
- **GIVEN** 用户已登录后台
|
||||
- **WHEN** 顶部导航加载未读通知数量
|
||||
- **THEN** 铃铛 MUST call `GET /api/admin/notifications/unread-count`
|
||||
- **AND** 数量 MUST display as `0`, `1` through `99`, or `99+`
|
||||
|
||||
#### Scenario: Open paginated notification drawer
|
||||
|
||||
- **WHEN** 用户点击顶部通知铃铛
|
||||
- **THEN** 页面 MUST display notification items from `GET /api/admin/notifications` using `page=1` and `page_size=10`
|
||||
- **AND** 抽屉 MUST provide 全部、审批、临期、同步/系统分类
|
||||
- **AND** 抽屉 MUST provide page controls based on the response `total`
|
||||
- **AND** changing page MUST replace the visible items instead of appending items from a scroll event
|
||||
- **AND** changing category MUST reset the page to 1
|
||||
- **AND** 抽屉 MUST provide an entry to `/notifications`
|
||||
@@ -0,0 +1,15 @@
|
||||
## 1. Notification State
|
||||
|
||||
- [x] 1.1 在通知 store 中保存当前页、每页数量和列表总数。
|
||||
- [x] 1.2 支持按指定页请求通知,并用新结果替换当前列表。
|
||||
|
||||
## 2. Notification Drawer
|
||||
|
||||
- [x] 2.1 在通知列表底部增加页码分页控件,默认每页 10 条。
|
||||
- [x] 2.2 切换页码时重新请求列表并回到列表顶部。
|
||||
- [x] 2.3 切换分类时重置页码并保持现有分类、已读和跳转行为。
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] 3.1 验证分页请求携带正确的 `page` 和 `page_size`,且列表不会累加旧页数据。
|
||||
- [x] 3.2 运行通知相关 lint、类型、格式、样式和编码检查;当前仓库暂无通知专项测试。
|
||||
@@ -0,0 +1,17 @@
|
||||
# Change: 使用授权套餐候选项接口创建代理系列授权
|
||||
|
||||
## Why
|
||||
|
||||
新增代理系列授权时,通用套餐分页接口不能按当前操作者、目标店铺和套餐系列返回真正可授权的套餐,也不能提供目标店铺已授权状态。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增代理系列授权弹窗的套餐选择改用 `GET /api/admin/shop-series-grants/package-options`。
|
||||
- 仅在已选择目标店铺和套餐系列后请求候选项接口,并原样传递 `shop_id` 与 `series_id`。
|
||||
- 以接口候选项作为可选集合,保留候选项的授权状态,且不再由前端额外过滤赠送套餐。
|
||||
- 为候选项接口增加前端 API 方法与类型定义,并用其价格字段初始化套餐成本价和建议零售价校验数据。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `package-series-allocation`
|
||||
- Affected code: `src/api/modules/shopSeriesGrant.ts`, `src/types/api/packageManagement.ts`, `src/views/package-management/series-grants/index.vue`
|
||||
@@ -0,0 +1,17 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 授权创建使用店铺系列套餐候选项
|
||||
|
||||
系统 SHALL 在新增代理系列授权时,使用 `GET /api/admin/shop-series-grants/package-options` 获取套餐候选项。前端 MUST 在目标店铺和套餐系列均已选择后,原样传递非空的 `shop_id` 与 `series_id`;接口返回的候选项 SHALL 作为可选套餐及其价格、授权状态的权威来源。
|
||||
|
||||
#### Scenario: 选择店铺和套餐系列后加载候选项
|
||||
|
||||
- **WHEN** 用户在新增代理系列授权弹窗中已选择目标店铺和套餐系列
|
||||
- **THEN** 前端请求 `/api/admin/shop-series-grants/package-options` 并携带所选 `shop_id` 与 `series_id`
|
||||
- **AND** 前端展示返回的候选套餐而不再调用通用套餐分页接口作为该弹窗的候选来源
|
||||
|
||||
#### Scenario: 更换店铺或套餐系列
|
||||
|
||||
- **WHEN** 用户更换目标店铺或套餐系列
|
||||
- **THEN** 前端清空此前的套餐候选项和已选择套餐
|
||||
- **AND** 仅使用新组合返回的候选项进行后续选择
|
||||
@@ -0,0 +1,14 @@
|
||||
## 1. API and Types
|
||||
|
||||
- [x] 1.1 Add the package-options API method and response types defined by the August interface document.
|
||||
|
||||
## 2. Create Grant Dialog
|
||||
|
||||
- [x] 2.1 Load package candidates only after both shop and series are selected, passing their IDs unchanged.
|
||||
- [x] 2.2 Use returned candidates for selection, package names, cost prices and price validation.
|
||||
- [x] 2.3 Reset candidate and selected-package state when the shop or series changes.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] 3.1 Verify request parameters, candidate rendering and selected-package submission.
|
||||
- [x] 3.2 Run lint and type checks for changed files.
|
||||
@@ -76,7 +76,7 @@
|
||||
}
|
||||
|
||||
const getEntrySignature = (html: string) => {
|
||||
const matches = html.match(/(?:src|href)="[^"]*\/assets\/[^""]+\.(?:js|css)"/g)
|
||||
const matches = html.match(/(?:src|href)="[^"]*\/assets\/[^"]+\.(?:js|css)"/g)
|
||||
return matches?.sort().join('|') || ''
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
[
|
||||
navigator.userAgent,
|
||||
navigator.language,
|
||||
navigator.platform,
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
`${screen.width}x${screen.height}x${screen.colorDepth}`
|
||||
].join('|')
|
||||
|
||||
@@ -88,4 +88,14 @@ export class AgentRechargeService extends BaseService {
|
||||
): Promise<BaseResponse<void>> {
|
||||
return this.post<BaseResponse<void>>(`/api/admin/agent-recharges/${id}/reject`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 补发历史线下代理充值审批
|
||||
* @param id 充值记录ID
|
||||
*/
|
||||
static triggerApproval(id: number): Promise<BaseResponse<AgentRecharge>> {
|
||||
return this.post<BaseResponse<AgentRecharge>>(
|
||||
`/api/admin/agent-recharges/${id}/trigger-approval`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,6 @@ import type {
|
||||
AssetOrdersResponse,
|
||||
UpdateAssetRealnameStatusRequest,
|
||||
DtoUpdateAssetRealnameStatusResponse,
|
||||
AssetOperationLogsResponse,
|
||||
AssetOperationLogsParams,
|
||||
AssetPackageUsageRecord,
|
||||
UpdateAssetPackageUsedDataRequest,
|
||||
UpdateAssetPackageExpiresAtRequest,
|
||||
@@ -311,22 +309,4 @@ export class AssetService extends BaseService {
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
// ========== 资产操作审计日志 ==========
|
||||
|
||||
/**
|
||||
* 查询资产操作审计日志
|
||||
* GET /api/admin/assets/:identifier/operation-logs
|
||||
* @param identifier 资产标识符
|
||||
* @param params 查询参数
|
||||
*/
|
||||
static getOperationLogs(
|
||||
identifier: string,
|
||||
params?: AssetOperationLogsParams
|
||||
): Promise<BaseResponse<AssetOperationLogsResponse>> {
|
||||
return this.get<BaseResponse<AssetOperationLogsResponse>>(
|
||||
`/api/admin/assets/${identifier}/operation-logs`,
|
||||
params
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { isPlatformAuditAccount } from '@/utils/business/auditAccess'
|
||||
import type {
|
||||
AuditActorEventQuery,
|
||||
AuditActorKind,
|
||||
@@ -26,12 +28,28 @@ import type {
|
||||
} from '@/types/api'
|
||||
|
||||
export class AuditService extends BaseService {
|
||||
private static ensurePlatformAuditAccess() {
|
||||
const userStore = useUserStore()
|
||||
if (!isPlatformAuditAccount(userStore.info.user_type, userStore.isSuperAdmin)) {
|
||||
throw new Error('当前账号无权访问平台审计接口')
|
||||
}
|
||||
}
|
||||
|
||||
private static ensureSubjectActivityAccess(subject: 'agent' | 'enterprise') {
|
||||
const userType = Number(useUserStore().info.user_type)
|
||||
if ((subject === 'agent' && userType !== 3) || (subject === 'enterprise' && userType !== 4)) {
|
||||
throw new Error('当前账号无权访问主体活动接口')
|
||||
}
|
||||
}
|
||||
|
||||
static getEvents(params?: AuditEventQuery): Promise<BaseResponse<AuditEventPage>> {
|
||||
this.ensurePlatformAuditAccess()
|
||||
return this.get('/api/admin/audit/events', params)
|
||||
}
|
||||
|
||||
static getEventDetail(eventId: string): Promise<BaseResponse<AuditEventDetail>> {
|
||||
return this.get(`/api/admin/audit/events/${encodeURIComponent(eventId)}`)
|
||||
this.ensurePlatformAuditAccess()
|
||||
return this.get(`/api/admin/audit/events/${eventId}`)
|
||||
}
|
||||
|
||||
static getActorEvents(
|
||||
@@ -39,6 +57,7 @@ export class AuditService extends BaseService {
|
||||
id: string,
|
||||
params?: AuditActorEventQuery
|
||||
): Promise<BaseResponse<AuditEventPage>> {
|
||||
this.ensurePlatformAuditAccess()
|
||||
return this.get(
|
||||
`/api/admin/audit/actors/${encodeURIComponent(kind)}/${encodeURIComponent(id)}/events`,
|
||||
params
|
||||
@@ -48,6 +67,7 @@ export class AuditService extends BaseService {
|
||||
static searchResources(
|
||||
params: AuditResourceSearchQuery
|
||||
): Promise<BaseResponse<AuditResourceSearchPage>> {
|
||||
this.ensurePlatformAuditAccess()
|
||||
return this.get('/api/admin/audit/resources/search', params)
|
||||
}
|
||||
|
||||
@@ -56,6 +76,7 @@ export class AuditService extends BaseService {
|
||||
resourceId: string,
|
||||
params?: AuditResourceTimelineQuery
|
||||
): Promise<BaseResponse<AuditEventPage>> {
|
||||
this.ensurePlatformAuditAccess()
|
||||
return this.get(
|
||||
`/api/admin/audit/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/timeline`,
|
||||
params
|
||||
@@ -63,40 +84,48 @@ export class AuditService extends BaseService {
|
||||
}
|
||||
|
||||
static getRequestTimeline(requestId: string): Promise<BaseResponse<AuditLinkTimeline>> {
|
||||
this.ensurePlatformAuditAccess()
|
||||
return this.get(`/api/admin/audit/requests/${encodeURIComponent(requestId)}/timeline`)
|
||||
}
|
||||
|
||||
static getCorrelationTimeline(correlationId: string): Promise<BaseResponse<AuditLinkTimeline>> {
|
||||
this.ensurePlatformAuditAccess()
|
||||
return this.get(`/api/admin/audit/correlations/${encodeURIComponent(correlationId)}/timeline`)
|
||||
}
|
||||
|
||||
static getFinanceTimeline(
|
||||
params: AuditFinanceQuery
|
||||
): Promise<BaseResponse<AuditFinanceTimelinePage>> {
|
||||
this.ensurePlatformAuditAccess()
|
||||
return this.get('/api/admin/audit/finance/timeline', params)
|
||||
}
|
||||
|
||||
static getRiskOverview(params?: AuditRiskQuery): Promise<BaseResponse<AuditRiskOverview>> {
|
||||
this.ensurePlatformAuditAccess()
|
||||
return this.get('/api/admin/audit/risks/overview', params)
|
||||
}
|
||||
|
||||
static getRiskEvents(params?: AuditRiskEventQuery): Promise<BaseResponse<AuditRiskEventPage>> {
|
||||
this.ensurePlatformAuditAccess()
|
||||
return this.get('/api/admin/audit/risks/events', params)
|
||||
}
|
||||
|
||||
static getIntegrationOverview(
|
||||
params?: IntegrationQuery & { bucket?: 'hour' | 'day' }
|
||||
): Promise<BaseResponse<IntegrationOverview>> {
|
||||
this.ensurePlatformAuditAccess()
|
||||
return this.get('/api/admin/audit/integrations/overview', params)
|
||||
}
|
||||
|
||||
static getIntegrations(params?: IntegrationQuery): Promise<BaseResponse<IntegrationListPage>> {
|
||||
this.ensurePlatformAuditAccess()
|
||||
return this.get('/api/admin/audit/integrations', params)
|
||||
}
|
||||
|
||||
static getIntegrationDetail(
|
||||
integrationId: string
|
||||
): Promise<BaseResponse<IntegrationDetailResponse>> {
|
||||
this.ensurePlatformAuditAccess()
|
||||
return this.get(`/api/admin/audit/integrations/${encodeURIComponent(integrationId)}`)
|
||||
}
|
||||
|
||||
@@ -105,6 +134,7 @@ export class AuditService extends BaseService {
|
||||
identifier: string,
|
||||
params?: AuditSubjectActivityQuery
|
||||
): Promise<BaseResponse<AuditSubjectActivityPage>> {
|
||||
this.ensureSubjectActivityAccess('agent')
|
||||
return this.get(
|
||||
`/api/admin/agent/resource-activities/${encodeURIComponent(resourceType)}/${encodeURIComponent(identifier)}`,
|
||||
params
|
||||
@@ -116,6 +146,7 @@ export class AuditService extends BaseService {
|
||||
identifier: string,
|
||||
params?: AuditSubjectActivityQuery
|
||||
): Promise<BaseResponse<AuditSubjectActivityPage>> {
|
||||
this.ensureSubjectActivityAccess('enterprise')
|
||||
return this.get(
|
||||
`/api/admin/enterprise/resource-activities/${encodeURIComponent(resourceType)}/${encodeURIComponent(identifier)}`,
|
||||
params
|
||||
|
||||
@@ -45,4 +45,12 @@ export class RefundService extends BaseService {
|
||||
static resubmitRefund(id: number, data: ResubmitRefundRequest): Promise<BaseResponse<void>> {
|
||||
return this.post<BaseResponse<void>>(`/api/admin/refunds/${id}/resubmit`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 补发历史退款审批
|
||||
* @param id 退款申请ID
|
||||
*/
|
||||
static triggerApproval(id: number): Promise<BaseResponse<Refund>> {
|
||||
return this.post<BaseResponse<Refund>>(`/api/admin/refunds/${id}/trigger-approval`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
CreateShopSeriesGrantRequest,
|
||||
UpdateShopSeriesGrantRequest,
|
||||
ManageGrantPackagesRequest,
|
||||
ShopSeriesGrantPackageOptionsResponse,
|
||||
BaseResponse,
|
||||
PaginationResponse
|
||||
} from '@/types/api'
|
||||
@@ -45,6 +46,20 @@ export class ShopSeriesGrantService extends BaseService {
|
||||
return this.getOne<ShopSeriesGrantResponse>(`/api/admin/shop-series-grants/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理系列授权可选套餐
|
||||
* GET /api/admin/shop-series-grants/package-options
|
||||
*/
|
||||
static getPackageOptions(
|
||||
shopId: number,
|
||||
seriesId: number
|
||||
): Promise<BaseResponse<ShopSeriesGrantPackageOptionsResponse>> {
|
||||
return this.getOne<ShopSeriesGrantPackageOptionsResponse>(
|
||||
'/api/admin/shop-series-grants/package-options',
|
||||
{ shop_id: shopId, series_id: seriesId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新代理系列授权
|
||||
* PUT /api/admin/shop-series-grants/{id}
|
||||
|
||||
@@ -72,19 +72,24 @@
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: 100px !important;
|
||||
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
|
||||
border-radius: 12px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
padding: 20px 24px 16px !important;
|
||||
margin-right: 0 !important;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
.el-dialog__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 25px 0 !important;
|
||||
padding: 20px 24px !important;
|
||||
position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<ElDialog v-model="visible" title="批量修改实名顺序" width="520px" @closed="resetPolicy">
|
||||
<ElForm label-width="110px">
|
||||
<ElDialog v-model="visible" title="批量修改实名顺序" width="40%" @closed="resetPolicy">
|
||||
<ElForm label-width="80px">
|
||||
<ElFormItem label="已选数量">
|
||||
<span class="selected-count">{{ selectedCount }} {{ assetUnit }}</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="实名认证策略">
|
||||
<ElFormItem label="认证策略">
|
||||
<ElRadioGroup v-model="policy" :disabled="loading">
|
||||
<ElRadio value="none">无需实名</ElRadio>
|
||||
<ElRadio value="before_order">先实名后购买</ElRadio>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ElDialog v-model="dialogVisible" title="创建退款申请" width="40%" @closed="handleDialogClosed">
|
||||
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="120px">
|
||||
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="80px">
|
||||
<ElFormItem label="订单号" prop="order_id">
|
||||
<ElSelect
|
||||
v-if="!hasInitialOrder"
|
||||
@@ -23,7 +23,7 @@
|
||||
</ElSelect>
|
||||
<ElInput v-else :model-value="props.initialOrderNo" disabled style="width: 100%" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="申请退款金额" prop="requested_refund_amount">
|
||||
<ElFormItem label="退款金额" prop="requested_refund_amount">
|
||||
<ElInputNumber
|
||||
v-model="formData.requested_refund_amount"
|
||||
:min="0"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<ElDialog v-model="visible" :title="title" width="520px" destroy-on-close>
|
||||
<ElDialog v-model="visible" :title="title" width="40%" destroy-on-close>
|
||||
<ElAlert type="info" :closable="false" show-icon class="export-rule-alert">
|
||||
<template #title>{{ description }}</template>
|
||||
</ElAlert>
|
||||
|
||||
<ElForm label-width="100px" class="export-task-form">
|
||||
<ElForm label-width="80px" class="export-task-form">
|
||||
<ElFormItem label="导出场景">
|
||||
<ElTag>{{ sceneName }}</ElTag>
|
||||
</ElFormItem>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<ElDrawer
|
||||
v-model="dialogVisible"
|
||||
title="操作审计日志"
|
||||
direction="rtl"
|
||||
size="70%"
|
||||
:before-close="handleClose"
|
||||
>
|
||||
<OperationLogsCard :asset-identifier="identifier" :download-permission="downloadPermission" />
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElDrawer } from 'element-plus'
|
||||
import OperationLogsCard from '@/views/asset-management/asset-information/components/OperationLogsCard.vue'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
identifier?: string
|
||||
downloadPermission?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const dialogVisible = ref(props.modelValue)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
dialogVisible.value = val
|
||||
}
|
||||
)
|
||||
|
||||
watch(dialogVisible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -279,8 +279,8 @@
|
||||
}
|
||||
|
||||
.package-config-field-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
title="手动更新卡实名状态"
|
||||
width="400px"
|
||||
width="30%"
|
||||
@closed="handleDialogClosed"
|
||||
>
|
||||
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||||
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="80px">
|
||||
<ElFormItem label="资产标识">
|
||||
<span style="font-weight: bold; color: #409eff">{{ assetIdentifier }}</span>
|
||||
</ElFormItem>
|
||||
|
||||
@@ -57,8 +57,8 @@
|
||||
import { Close, Document, UploadFilled } from '@element-plus/icons-vue'
|
||||
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'
|
||||
import type { RefundAttachment } from '@/types/api/refund'
|
||||
import type { FilePurpose } from '@/api/modules/storage'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string[] | string
|
||||
@@ -202,7 +202,10 @@
|
||||
|
||||
if (props.singleColumnCsv) {
|
||||
const content = await file.text()
|
||||
const rows = content.replace(/^\uFEFF/, '').split(/\r?\n/).filter(Boolean)
|
||||
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)
|
||||
@@ -250,7 +253,7 @@
|
||||
const uploadUrlRes = await StorageService.getUploadUrl({
|
||||
file_name: file.name,
|
||||
content_type: contentType,
|
||||
purpose: props.purpose
|
||||
purpose: props.purpose
|
||||
})
|
||||
|
||||
if (uploadUrlRes.code !== 0) {
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
<template #default="{ row }">{{ formatDateTime(row.occurred_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="动作" min-width="180">
|
||||
<template #default="{ row }">{{ row.action_name || '-' }}</template>
|
||||
<template #default="{ row }">
|
||||
<ElButton v-if="detailEnabled" link type="primary" @click="$emit('detail', row)">
|
||||
{{ row.action_name || '-' }}
|
||||
</ElButton>
|
||||
<span v-else>{{ row.action_name || '-' }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="摘要" prop="summary" min-width="240" show-overflow-tooltip />
|
||||
<ElTableColumn label="操作者" min-width="150">
|
||||
@@ -30,11 +35,6 @@
|
||||
}}</ElTag></template
|
||||
>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="110" fixed="right">
|
||||
<template #default="{ row }"
|
||||
><ElButton link type="primary" @click="$emit('detail', row)">查看详情</ElButton></template
|
||||
>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</template>
|
||||
@@ -44,7 +44,12 @@
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { auditResultMeta, auditRiskMeta } from '@/utils/business/audit'
|
||||
|
||||
defineProps<{ items: AuditEventView[]; loading?: boolean }>()
|
||||
withDefaults(
|
||||
defineProps<{ items: AuditEventView[]; loading?: boolean; detailEnabled?: boolean }>(),
|
||||
{
|
||||
detailEnabled: true
|
||||
}
|
||||
)
|
||||
defineEmits<{ detail: [row: AuditEventView] }>()
|
||||
const resultMeta = (row: AuditEventView) =>
|
||||
auditResultMeta[row.result] || { label: row.result, type: 'info' as const }
|
||||
|
||||
@@ -21,12 +21,172 @@
|
||||
<article class="timeline-node">
|
||||
<div class="node-head">
|
||||
<strong>{{ item.action_name }}</strong>
|
||||
<ElTag size="small" :type="auditResultMeta[item.result]?.type">{{
|
||||
auditResultMeta[item.result]?.label || item.result
|
||||
}}</ElTag>
|
||||
<span class="node-tags">
|
||||
<ElTag size="small" :type="auditResultMeta[item.result]?.type">{{
|
||||
auditResultMeta[item.result]?.label || item.result
|
||||
}}</ElTag>
|
||||
<ElTag
|
||||
v-if="isFullEventTimeline"
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="auditRiskMeta[item.risk_level]?.type"
|
||||
>
|
||||
{{ auditRiskMeta[item.risk_level]?.label || item.risk_level }}风险
|
||||
</ElTag>
|
||||
</span>
|
||||
</div>
|
||||
<p>{{ item.summary || '-' }}</p>
|
||||
<div class="node-meta">
|
||||
<p v-if="!isFullEventTimeline">{{ item.summary || '-' }}</p>
|
||||
<template v-if="isFullEventTimeline">
|
||||
<ElDescriptions class="actor-event-summary" :column="4" size="small" border>
|
||||
<ElDescriptionsItem label="分类">
|
||||
{{ auditCategoryLabels[item.category] || item.category }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="来源">
|
||||
{{ auditSourceLabels[item.source] || item.source }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="操作者">
|
||||
{{ actorDisplay(item) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="业务范围">
|
||||
{{ scopeDisplay(item) }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<ElCollapse class="actor-event-sections">
|
||||
<ElCollapseItem
|
||||
v-if="userStore.isSuperAdmin && hasRequestContext(item)"
|
||||
title="请求上下文"
|
||||
name="request"
|
||||
>
|
||||
<ElDescriptions :column="1" size="small" border>
|
||||
<ElDescriptionsItem label="请求">
|
||||
{{
|
||||
[item.request_method, item.request_path].filter(Boolean).join(' ') || '-'
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="request_id">{{
|
||||
item.request_id || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="correlation_id">{{
|
||||
item.correlation_id || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="IP 地址">{{
|
||||
item.ip_address || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="User-Agent">{{
|
||||
item.user_agent || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCollapseItem>
|
||||
<ElCollapseItem v-if="hasBatchData(item)" title="批次执行统计" name="batch">
|
||||
<ElDescriptions :column="3" size="small" border>
|
||||
<ElDescriptionsItem label="处理总数">{{
|
||||
item.batch_total || 0
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="成功数">{{
|
||||
item.success_count || 0
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="失败数">{{
|
||||
item.fail_count || 0
|
||||
}}</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCollapseItem>
|
||||
<ElCollapseItem
|
||||
v-if="item.error_code || item.error_summary"
|
||||
title="错误信息"
|
||||
name="error"
|
||||
>
|
||||
<ElDescriptions :column="1" size="small" border>
|
||||
<ElDescriptionsItem label="错误码">{{
|
||||
item.error_code || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="错误摘要">{{
|
||||
item.error_summary || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCollapseItem>
|
||||
<ElCollapseItem
|
||||
v-if="item.resources?.length"
|
||||
title="相关资源与快照"
|
||||
name="resources"
|
||||
>
|
||||
<div class="actor-resource-list">
|
||||
<section
|
||||
v-for="resource in item.resources"
|
||||
:key="resourceKey(resource)"
|
||||
class="actor-resource"
|
||||
>
|
||||
<div class="actor-resource__title">
|
||||
<strong>{{
|
||||
resource.display_name ||
|
||||
resource.resource_key ||
|
||||
resource.resource_id ||
|
||||
'-'
|
||||
}}</strong>
|
||||
<ElTag size="small" effect="plain">
|
||||
{{ auditResourceRelationLabels[resource.relation] || resource.relation }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<ElDescriptions :column="2" size="small">
|
||||
<ElDescriptionsItem label="资源类型">
|
||||
{{
|
||||
auditResourceTypeLabels[resource.resource_type] ||
|
||||
resource.resource_type
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="业务角色">{{
|
||||
resource.role || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="资源标识">
|
||||
{{ resource.resource_key || resource.resource_id || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="主体摘要">{{
|
||||
resource.subject_summary || '-'
|
||||
}}</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
<ElCollapse class="resource-json-sections">
|
||||
<ElCollapseItem
|
||||
v-if="hasJsonContent(resource.identity_snapshot)"
|
||||
title="身份快照"
|
||||
name="identity"
|
||||
>
|
||||
<pre>{{ auditJson(resource.identity_snapshot) }}</pre>
|
||||
</ElCollapseItem>
|
||||
<ElCollapseItem
|
||||
v-if="hasJsonContent(resource.subject_data)"
|
||||
title="主体安全数据"
|
||||
name="subject"
|
||||
>
|
||||
<pre>{{ auditJson(resource.subject_data) }}</pre>
|
||||
</ElCollapseItem>
|
||||
<ElCollapseItem
|
||||
v-if="hasJsonContent(resource.before_data)"
|
||||
title="变更前"
|
||||
name="before"
|
||||
>
|
||||
<pre>{{ auditJson(resource.before_data) }}</pre>
|
||||
</ElCollapseItem>
|
||||
<ElCollapseItem
|
||||
v-if="hasJsonContent(resource.after_data)"
|
||||
title="变更后"
|
||||
name="after"
|
||||
>
|
||||
<pre>{{ auditJson(resource.after_data) }}</pre>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</section>
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
<ElCollapseItem
|
||||
v-if="userStore.isSuperAdmin && hasJsonContent(item.metadata)"
|
||||
title="扩展元数据"
|
||||
name="metadata"
|
||||
>
|
||||
<pre>{{ auditJson(item.metadata) }}</pre>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</template>
|
||||
<div v-else class="node-meta">
|
||||
<span>{{ item.actor_name || item.actor_id || '-' }}</span>
|
||||
<span>{{ auditSourceLabels[item.source] || item.source }}</span>
|
||||
</div>
|
||||
@@ -45,16 +205,65 @@
|
||||
<article class="timeline-node">
|
||||
<div class="node-head">
|
||||
<strong>{{ node.title }}</strong>
|
||||
<ElTag size="small" effect="plain">{{
|
||||
auditResultDisplay(node.result, node.result_name)
|
||||
}}</ElTag>
|
||||
<span class="node-tags">
|
||||
<ElTag size="small" effect="plain">{{
|
||||
auditResultDisplay(node.result, node.result_name)
|
||||
}}</ElTag>
|
||||
<ElTag v-if="node.reference_only" size="small" type="info" effect="plain">
|
||||
只读引用
|
||||
</ElTag>
|
||||
</span>
|
||||
</div>
|
||||
<p>{{ node.summary || '-' }}</p>
|
||||
<div class="node-meta">
|
||||
<p v-if="!isStructuredLinkTimeline">{{ node.summary || '-' }}</p>
|
||||
<template v-if="isStructuredLinkTimeline">
|
||||
<ElDescriptions class="actor-event-summary" :column="4" size="small" border>
|
||||
<ElDescriptionsItem label="事实来源">
|
||||
{{ recordSourceLabels[node.record_source] || node.record_source }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="稳定编码">{{ node.code || '-' }}</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
<ElCollapse class="actor-event-sections">
|
||||
<ElCollapseItem
|
||||
v-if="userStore.isSuperAdmin && hasJsonContent(node.fidelity)"
|
||||
title="数据完整性"
|
||||
name="fidelity"
|
||||
>
|
||||
<pre>{{ auditJson(node.fidelity) }}</pre>
|
||||
</ElCollapseItem>
|
||||
<ElCollapseItem v-if="node.resources?.length" title="相关资源" name="resources">
|
||||
<div class="actor-resource-list">
|
||||
<section
|
||||
v-for="(resource, index) in node.resources"
|
||||
:key="
|
||||
[
|
||||
resource.resource_type,
|
||||
resource.resource_id || resource.resource_key,
|
||||
index
|
||||
].join(':')
|
||||
"
|
||||
class="actor-resource"
|
||||
>
|
||||
<div class="actor-resource__title">
|
||||
<strong>{{ resource.display_name || resource.resource_key || '-' }}</strong>
|
||||
</div>
|
||||
<ElDescriptions :column="2" size="small">
|
||||
<ElDescriptionsItem label="资源类型">
|
||||
{{
|
||||
auditResourceTypeLabels[resource.resource_type] ||
|
||||
resource.resource_type
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="resource.resource_key" label="资源业务 Key">
|
||||
{{ resource.resource_key }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</section>
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</template>
|
||||
<div v-else class="node-meta">
|
||||
<span>{{ recordSourceLabels[node.record_source] || node.record_source }}</span>
|
||||
<ElTag v-if="node.reference_only" size="small" type="info" effect="plain">
|
||||
只读引用
|
||||
</ElTag>
|
||||
</div>
|
||||
</article>
|
||||
</ElTimelineItem>
|
||||
@@ -75,13 +284,63 @@
|
||||
{{ auditResultDisplay(item.result, item.result_name) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<p v-if="item.amount !== null && item.amount !== undefined">
|
||||
金额:{{ formatMoney(item.amount, item.currency) }}
|
||||
</p>
|
||||
<div class="node-meta">
|
||||
<span>{{ item.code || '-' }}</span>
|
||||
<span>{{ item.record_source || '-' }}</span>
|
||||
</div>
|
||||
<ElDescriptions class="actor-event-summary" :column="4" size="small" border>
|
||||
<ElDescriptionsItem
|
||||
v-if="item.amount !== null && item.amount !== undefined"
|
||||
label="金额"
|
||||
>
|
||||
{{ formatMoney(item.amount, item.currency) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem
|
||||
v-if="item.balance_before !== null && item.balance_before !== undefined"
|
||||
label="变更前余额"
|
||||
>
|
||||
{{ formatMoney(item.balance_before, item.currency) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem
|
||||
v-if="item.balance_after !== null && item.balance_after !== undefined"
|
||||
label="变更后余额"
|
||||
>
|
||||
{{ formatMoney(item.balance_after, item.currency) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="币种">{{ item.currency || '-' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="事实来源">
|
||||
{{ financeRecordSourceLabel(item.record_source) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="稳定编码">{{ item.code || '-' }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="钱包类型">
|
||||
{{ financeWalletTypeLabel(item.wallet?.resource_type) }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
<ElCollapse
|
||||
v-if="item.amount_authority || hasJsonContent(item.facts)"
|
||||
class="actor-event-sections"
|
||||
>
|
||||
<ElCollapseItem v-if="item.amount_authority" title="金额权威来源" name="authority">
|
||||
<ElDescriptions :column="1" size="small" border>
|
||||
<ElDescriptionsItem label="是否权威">
|
||||
<ElTag
|
||||
size="small"
|
||||
:type="item.amount_authority.authoritative ? 'success' : 'info'"
|
||||
>
|
||||
{{ item.amount_authority.authoritative ? '是' : '否' }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="item.amount_authority.table" label="业务表">
|
||||
{{ item.amount_authority.table }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="item.amount_authority.field" label="金额字段">
|
||||
{{ item.amount_authority.field }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="item.amount_authority.conflict_rule" label="冲突规则">
|
||||
{{ item.amount_authority.conflict_rule }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCollapseItem>
|
||||
<ElCollapseItem v-if="hasJsonContent(item.facts)" title="安全结构化事实" name="facts">
|
||||
<pre>{{ auditJsonWithoutIds(item.facts) }}</pre>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</article>
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
@@ -138,62 +397,110 @@
|
||||
</template>
|
||||
|
||||
<template v-else-if="integrationDetail">
|
||||
<ElDescriptions :column="2" border>
|
||||
<ElDescriptionsItem label="提供方">
|
||||
{{ integrationDetail.identity.provider_name || integrationDetail.identity.provider }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="方向">
|
||||
{{ integrationDetail.identity.direction_name || integrationDetail.identity.direction }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="操作">
|
||||
{{ integrationDetail.identity.operation_name || integrationDetail.identity.operation }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="结果">
|
||||
<section class="integration-hero">
|
||||
<div>
|
||||
<span class="integration-hero__eyebrow">
|
||||
{{ integrationDetail.identity.provider_name || integrationDetail.identity.provider }}
|
||||
·
|
||||
{{
|
||||
integrationDetail.identity.direction_name || integrationDetail.identity.direction
|
||||
}}
|
||||
</span>
|
||||
<h3>{{
|
||||
integrationDetail.identity.operation_name || integrationDetail.identity.operation
|
||||
}}</h3>
|
||||
<p>{{ formatDateTime(integrationDetail.timestamps.created_at) }}</p>
|
||||
</div>
|
||||
<div class="integration-hero__result">
|
||||
<ElTag
|
||||
size="small"
|
||||
:type="integrationCategoryMeta[integrationDetail.result.category]?.type || 'info'"
|
||||
>
|
||||
{{ auditResultDisplay(integrationDetail.result.code, integrationDetail.result.name) }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="耗时">
|
||||
{{ integrationDetail.result.duration_ms }} ms
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="HTTP 状态">
|
||||
{{ integrationDetail.result.http_status ?? '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="integrationDetail.identity.external_id" label="外部业务标识">
|
||||
{{ integrationDetail.identity.external_id }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="integrationDetail.resource?.key" label="资源业务标识">
|
||||
{{ integrationDetail.resource.key }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem
|
||||
v-if="integrationDetail.result.provider_message"
|
||||
label="外部结果摘要"
|
||||
:span="2"
|
||||
>
|
||||
{{ integrationDetail.result.provider_message }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
<section
|
||||
v-if="
|
||||
hasJsonContent(integrationDetail.content.request_summary) ||
|
||||
hasJsonContent(integrationDetail.content.response_summary) ||
|
||||
hasJsonContent(integrationDetail.content.metadata)
|
||||
"
|
||||
class="json-section"
|
||||
>
|
||||
<strong>{{ integrationDetail.result.duration_ms ?? '-' }} ms</strong>
|
||||
<span>处理耗时</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="integration-detail-grid">
|
||||
<section class="integration-panel">
|
||||
<h4>交互信息</h4>
|
||||
<ElDescriptions :column="2" size="small">
|
||||
<ElDescriptionsItem label="提供方">
|
||||
{{
|
||||
integrationDetail.identity.provider_name || integrationDetail.identity.provider
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="方向">
|
||||
{{
|
||||
integrationDetail.identity.direction_name || integrationDetail.identity.direction
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="操作" :span="2">
|
||||
{{
|
||||
integrationDetail.identity.operation_name || integrationDetail.identity.operation
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem
|
||||
v-if="integrationDetail.result.http_status != null"
|
||||
label="HTTP 状态"
|
||||
>
|
||||
{{ integrationDetail.result.http_status }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="状态是否变化">
|
||||
{{ integrationDetail.result.state_changed ? '是' : '否' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem
|
||||
v-if="integrationDetail.result.provider_message"
|
||||
label="外部结果说明"
|
||||
:span="2"
|
||||
>
|
||||
{{ integrationDetail.result.provider_message }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</section>
|
||||
|
||||
<section class="integration-panel">
|
||||
<h4>触发信息</h4>
|
||||
<ElDescriptions :column="1" size="small">
|
||||
<ElDescriptionsItem label="触发来源">
|
||||
{{ triggerSourceLabel(integrationDetail.trigger.source) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="业务场景">
|
||||
{{ triggerSceneLabel(integrationDetail.trigger.scene) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="开始时间">
|
||||
{{ formatDateTime(integrationDetail.timestamps.started_at) }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section v-if="hasVisibleIntegrationContent" class="integration-panel integration-content">
|
||||
<h4>交互内容摘要</h4>
|
||||
<pre v-if="hasJsonContent(integrationDetail.content.request_summary)">{{
|
||||
auditJson(integrationDetail.content.request_summary)
|
||||
}}</pre>
|
||||
<pre v-if="hasJsonContent(integrationDetail.content.response_summary)">{{
|
||||
auditJson(integrationDetail.content.response_summary)
|
||||
}}</pre>
|
||||
<pre v-if="hasJsonContent(integrationDetail.content.metadata)">{{
|
||||
auditJson(integrationDetail.content.metadata)
|
||||
}}</pre>
|
||||
<div class="integration-content-grid">
|
||||
<div
|
||||
v-if="hasJsonContentWithoutIds(integrationDetail.content.request_summary)"
|
||||
class="content-block"
|
||||
>
|
||||
<span>请求摘要</span>
|
||||
<pre>{{ auditJsonWithoutIds(integrationDetail.content.request_summary) }}</pre>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasJsonContentWithoutIds(integrationDetail.content.response_summary)"
|
||||
class="content-block"
|
||||
>
|
||||
<span>响应摘要</span>
|
||||
<pre>{{ auditJsonWithoutIds(integrationDetail.content.response_summary) }}</pre>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasJsonContentWithoutIds(integrationDetail.content.metadata)"
|
||||
class="content-block"
|
||||
>
|
||||
<span>扩展信息</span>
|
||||
<pre>{{ auditJsonWithoutIds(integrationDetail.content.metadata) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -226,13 +533,19 @@
|
||||
} from '@/types/api'
|
||||
import {
|
||||
auditJson,
|
||||
auditCategoryLabels,
|
||||
auditResourceRelationLabels,
|
||||
auditResourceTypeLabels,
|
||||
auditResultDisplay,
|
||||
auditResultMeta,
|
||||
auditRiskMeta,
|
||||
auditScopeTypeLabels,
|
||||
auditSourceLabels,
|
||||
integrationCategoryMeta
|
||||
} from '@/utils/business/audit'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { canLoadAuditInvestigation } from '@/utils/business/auditAccess'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import type { AuditInvestigationTarget } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -240,6 +553,7 @@
|
||||
target?: AuditInvestigationTarget | null
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -268,6 +582,12 @@
|
||||
const eventDetail = ref<AuditEventDetail>()
|
||||
const integrationDetail = ref<IntegrationDetailResponse>()
|
||||
let loadSequence = 0
|
||||
const isFullEventTimeline = computed(() =>
|
||||
['actor', 'resource'].includes(props.target?.mode || '')
|
||||
)
|
||||
const isStructuredLinkTimeline = computed(() =>
|
||||
['request', 'correlation'].includes(props.target?.mode || '')
|
||||
)
|
||||
|
||||
const recordSourceLabels: Record<string, string> = {
|
||||
audit_event: '审计事件',
|
||||
@@ -276,6 +596,21 @@
|
||||
asynq_task: '异步任务引用',
|
||||
domain_ledger_ref: '业务台账引用'
|
||||
}
|
||||
const financeRecordSourceLabels: Record<string, string> = {
|
||||
audit_event: '审计事件',
|
||||
domain_ledger_ref: '业务台账引用',
|
||||
agent_wallet_transaction: '代理钱包流水',
|
||||
asset_wallet_transaction: '资产钱包流水',
|
||||
agent_wallet_reservation: '代理钱包预留',
|
||||
order: '订单',
|
||||
payment: '支付',
|
||||
refund: '退款',
|
||||
agent_recharge: '代理充值',
|
||||
recharge_order: '充值订单',
|
||||
commission_record: '佣金记录',
|
||||
commission_withdrawal: '佣金提现',
|
||||
approval_instance: '审批实例'
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
eventItems.value = []
|
||||
@@ -288,12 +623,96 @@
|
||||
}
|
||||
const hasJsonContent = (value?: Record<string, unknown> | null) =>
|
||||
Boolean(value && Object.keys(value).length)
|
||||
const auditJsonWithoutIds = (value?: Record<string, unknown> | null) => {
|
||||
const removeIds = (current: unknown): unknown => {
|
||||
if (Array.isArray(current)) return current.map(removeIds)
|
||||
if (!current || typeof current !== 'object') return current
|
||||
return Object.fromEntries(
|
||||
Object.entries(current as Record<string, unknown>)
|
||||
.filter(([key]) => !/(^|[_-])ids?$/i.test(key))
|
||||
.map(([key, nested]) => [key, removeIds(nested)])
|
||||
)
|
||||
}
|
||||
return auditJson(removeIds(value))
|
||||
}
|
||||
const withoutIds = (value?: Record<string, unknown> | null) => {
|
||||
const removeIds = (current: unknown): unknown => {
|
||||
if (Array.isArray(current)) return current.map(removeIds)
|
||||
if (!current || typeof current !== 'object') return current
|
||||
return Object.fromEntries(
|
||||
Object.entries(current as Record<string, unknown>)
|
||||
.filter(([key]) => !/(^|[_-])ids?$/i.test(key))
|
||||
.map(([key, nested]) => [key, removeIds(nested)])
|
||||
)
|
||||
}
|
||||
return removeIds(value) as Record<string, unknown> | null | undefined
|
||||
}
|
||||
const hasJsonContentWithoutIds = (value?: Record<string, unknown> | null) =>
|
||||
hasJsonContent(withoutIds(value))
|
||||
const hasVisibleIntegrationContent = computed(() =>
|
||||
Boolean(
|
||||
integrationDetail.value &&
|
||||
[
|
||||
integrationDetail.value.content.request_summary,
|
||||
integrationDetail.value.content.response_summary,
|
||||
integrationDetail.value.content.metadata
|
||||
].some(hasJsonContentWithoutIds)
|
||||
)
|
||||
)
|
||||
const triggerSourceLabel = (source?: string | null) =>
|
||||
({ polling: '定时轮询', webhook: '外部回调', manual: '人工触发' })[source || ''] ||
|
||||
source ||
|
||||
'-'
|
||||
const triggerSceneLabel = (scene?: string | null) =>
|
||||
({ network_polling: '网络状态轮询' })[scene || ''] || scene || '-'
|
||||
const financeRecordSourceLabel = (source: string) =>
|
||||
financeRecordSourceLabels[source] || source || '-'
|
||||
const financeWalletTypeLabel = (type?: 'agent_wallet' | 'asset_wallet') =>
|
||||
type === 'agent_wallet' ? '代理钱包' : type === 'asset_wallet' ? '资产钱包' : '-'
|
||||
const actorDisplay = (item: AuditEventView) => {
|
||||
const actor = item.actor_name || item.actor_id || '-'
|
||||
const organizations = [
|
||||
item.actor_shop_name || (item.actor_shop_id ? `店铺 ID: ${item.actor_shop_id}` : ''),
|
||||
item.actor_enterprise_name ||
|
||||
(item.actor_enterprise_id ? `企业 ID: ${item.actor_enterprise_id}` : '')
|
||||
].filter(Boolean)
|
||||
return organizations.length ? `${actor}(${organizations.join(' · ')})` : actor
|
||||
}
|
||||
const scopeDisplay = (item: AuditEventView) => {
|
||||
const scope = item.scope_name || item.scope_id
|
||||
const type = auditScopeTypeLabels[item.scope_type] || item.scope_type
|
||||
return scope ? `${type} · ${scope}` : type || '-'
|
||||
}
|
||||
const hasRequestContext = (item: AuditEventView) =>
|
||||
Boolean(
|
||||
item.request_method ||
|
||||
item.request_path ||
|
||||
item.request_id ||
|
||||
item.correlation_id ||
|
||||
item.ip_address ||
|
||||
item.user_agent
|
||||
)
|
||||
const hasBatchData = (item: AuditEventView) =>
|
||||
Boolean(
|
||||
(item.batch_total || 0) > 0 || (item.success_count || 0) > 0 || (item.fail_count || 0) > 0
|
||||
)
|
||||
const resourceKey = (resource: AuditEventView['resources'][number]) =>
|
||||
[resource.resource_type, resource.resource_id || resource.resource_key, resource.sort_order]
|
||||
.filter((value) => value !== null && value !== undefined && value !== '')
|
||||
.join(':')
|
||||
const formatMoney = (amount: number, currency?: string | null) => {
|
||||
const value = (amount / 100).toFixed(2)
|
||||
return currency === 'CNY' || !currency ? `¥${value}` : `${value} ${currency}`
|
||||
}
|
||||
const load = async () => {
|
||||
if (!props.target) return
|
||||
if (
|
||||
!canLoadAuditInvestigation(props.target, userStore.info.user_type, userStore.isSuperAdmin)
|
||||
) {
|
||||
clear()
|
||||
ElMessage.error('当前账号无权访问该审计调查')
|
||||
return
|
||||
}
|
||||
const sequence = ++loadSequence
|
||||
clear()
|
||||
loading.value = true
|
||||
@@ -430,10 +849,143 @@
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.node-tags {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actor-event-summary {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.actor-event-sections {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.actor-resource-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.actor-resource {
|
||||
padding: 12px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.actor-resource__title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.resource-json-sections {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.json-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.integration-hero {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--el-color-primary-light-9),
|
||||
var(--el-fill-color-light)
|
||||
);
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.integration-hero__eyebrow,
|
||||
.integration-hero p,
|
||||
.integration-hero__result span,
|
||||
.content-block > span {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.integration-hero h3 {
|
||||
margin: 6px 0;
|
||||
font-size: 18px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.integration-hero p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.integration-hero__result {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
justify-items: end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.integration-hero__result strong {
|
||||
font-size: 20px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.integration-hero__result span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.integration-detail-grid,
|
||||
.integration-content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.integration-panel {
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.integration-panel h4 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.integration-panel :deep(.el-descriptions__label) {
|
||||
width: 76px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.integration-content {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.integration-content-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.content-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-block > span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.json-section h4 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
@@ -448,6 +1000,24 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.integration-hero,
|
||||
.integration-detail-grid,
|
||||
.integration-content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.integration-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.integration-hero__result {
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
<template>
|
||||
<div v-if="hasLinks" class="investigation-links" aria-label="调查入口">
|
||||
<ElButton v-if="eventId" link type="primary" @click="openEvent">事件详情</ElButton>
|
||||
<ElButton v-if="actorRef" link type="primary" @click="openActor"> 操作者行为时间线 </ElButton>
|
||||
<ElButton
|
||||
v-show="showResources"
|
||||
v-for="resource in stableResourceRefs"
|
||||
:key="`${resource.resource_type}:${resource.resource_id}`"
|
||||
link
|
||||
type="primary"
|
||||
:title="resourceTimelineLabel(resource)"
|
||||
:aria-label="resourceTimelineLabel(resource)"
|
||||
@click="openResource(resource)"
|
||||
>
|
||||
资源审计时间线
|
||||
{{ resourceTimelineLabel(resource) }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-show="showResources"
|
||||
v-for="resource in searchableResourceRefs"
|
||||
:key="`${resource.resource_type}:${resource.resource_key}`"
|
||||
link
|
||||
@@ -32,7 +35,7 @@
|
||||
业务关联链路
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-for="item in stableIntegrationRefs"
|
||||
v-for="item in visibleIntegrationRefs"
|
||||
:key="item.integration_id"
|
||||
link
|
||||
type="primary"
|
||||
@@ -52,15 +55,18 @@
|
||||
AuditSearchResourceType
|
||||
} from '@/types/api'
|
||||
import { openAuditInvestigation, openAuditResourceSearch } from './investigationController'
|
||||
import { auditResourceTypeLabels } from '@/utils/business/audit'
|
||||
import { resolveInvestigationReference } from '@/utils/business/auditNavigation'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
refs?: AuditInvestigationRefs | null
|
||||
currentEventId?: string
|
||||
showEmpty?: boolean
|
||||
showResources?: boolean
|
||||
}>(),
|
||||
{ currentEventId: '', showEmpty: true }
|
||||
{ showEmpty: true, showResources: true }
|
||||
)
|
||||
const searchableTypes = new Set<AuditSearchResourceType>([
|
||||
'iot_card',
|
||||
@@ -69,12 +75,7 @@
|
||||
'order',
|
||||
'refund'
|
||||
])
|
||||
const eventId = computed(
|
||||
() =>
|
||||
resolveInvestigationReference(props.refs?.event_id, {
|
||||
currentId: props.currentEventId
|
||||
}) || ''
|
||||
)
|
||||
const { hasAuth } = useAuth()
|
||||
const requestId = computed(() => resolveInvestigationReference(props.refs?.request_id) || '')
|
||||
const correlationId = computed(
|
||||
() => resolveInvestigationReference(props.refs?.correlation_id) || ''
|
||||
@@ -105,21 +106,20 @@
|
||||
const stableIntegrationRefs = computed(() =>
|
||||
(props.refs?.integration_refs || []).filter((item) => Boolean(item.integration_id))
|
||||
)
|
||||
const visibleIntegrationRefs = computed(() =>
|
||||
hasAuth(AUDIT_PERMISSIONS.auditEventIntegrationDetail) ? stableIntegrationRefs.value : []
|
||||
)
|
||||
const hasLinks = computed(() =>
|
||||
Boolean(
|
||||
eventId.value ||
|
||||
actorRef.value ||
|
||||
actorRef.value ||
|
||||
requestId.value ||
|
||||
correlationId.value ||
|
||||
stableResourceRefs.value.length ||
|
||||
searchableResourceRefs.value.length ||
|
||||
stableIntegrationRefs.value.length
|
||||
(props.showResources &&
|
||||
(stableResourceRefs.value.length || searchableResourceRefs.value.length)) ||
|
||||
visibleIntegrationRefs.value.length
|
||||
)
|
||||
)
|
||||
|
||||
const openEvent = () => {
|
||||
if (eventId.value) openAuditInvestigation({ mode: 'event', id: eventId.value })
|
||||
}
|
||||
const openIntegration = (id: string) => openAuditInvestigation({ mode: 'integration', id })
|
||||
const openTimeline = (mode: 'request' | 'correlation', id: string) =>
|
||||
openAuditInvestigation({ mode, id })
|
||||
@@ -135,6 +135,10 @@
|
||||
resourceType: resource.resource_type,
|
||||
id: resource.resource_id
|
||||
})
|
||||
const resourceTimelineLabel = (
|
||||
resource: AuditInvestigationResourceRef & { resource_id: string }
|
||||
) =>
|
||||
`资源审计时间线 · ${auditResourceTypeLabels[resource.resource_type] || resource.resource_type} · ${resource.resource_id}`
|
||||
const searchResource = (
|
||||
resource: AuditInvestigationResourceRef & {
|
||||
resource_type: AuditSearchResourceType
|
||||
|
||||
@@ -39,10 +39,10 @@
|
||||
})
|
||||
|
||||
// 合并默认配置和自定义配置
|
||||
const config = reactive({
|
||||
const config = computed(() => ({
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${prop.item.label}`,
|
||||
...(prop.item.config || {})
|
||||
})
|
||||
}))
|
||||
|
||||
// 选择框值变化处理函数
|
||||
const changeValue = (val: unknown): void => {
|
||||
|
||||
@@ -65,10 +65,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- 通知 -->
|
||||
<div
|
||||
class="btn-box notice-btn"
|
||||
@click.stop="showNotice = !showNotice"
|
||||
>
|
||||
<div class="btn-box notice-btn" @click.stop="showNotice = !showNotice">
|
||||
<div class="btn notice-button">
|
||||
<i class="iconfont-sys"></i>
|
||||
<span v-if="unreadCount > 0" class="notice-count">{{ formattedUnreadCount }}</span>
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
:key="tab.value"
|
||||
type="button"
|
||||
:class="{ active: activeCategory === tab.value }"
|
||||
@click="activeCategory = tab.value"
|
||||
@click="handleCategoryChange(tab.value)"
|
||||
>
|
||||
{{ tab.label }}<span v-if="tab.count"> ({{ tab.count }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div v-loading="notificationStore.loading" class="scroll">
|
||||
<div ref="notificationListRef" v-loading="notificationStore.loading" class="scroll">
|
||||
<button
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
@@ -43,6 +43,21 @@
|
||||
<p>暂无通知</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="notificationStore.notificationTotal > notificationStore.notificationPageSize"
|
||||
class="pagination"
|
||||
>
|
||||
<ElPagination
|
||||
:current-page="notificationStore.notificationPage"
|
||||
:page-size="notificationStore.notificationPageSize"
|
||||
:total="notificationStore.notificationTotal"
|
||||
:pager-count="5"
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
small
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,6 +79,7 @@
|
||||
const router = useRouter()
|
||||
const notificationStore = useNotificationStore()
|
||||
const activeCategory = ref<'all' | NotificationCategory>('all')
|
||||
const notificationListRef = ref<HTMLElement | null>(null)
|
||||
const visible = computed(() => props.modelValue)
|
||||
|
||||
const categoryMatches = (item: NotificationItem, category: string) => {
|
||||
@@ -96,6 +112,24 @@
|
||||
|
||||
const close = () => emit('update:modelValue', false)
|
||||
|
||||
const loadNotificationPage = async (page: number) => {
|
||||
try {
|
||||
await notificationStore.loadNotifications(page)
|
||||
notificationListRef.value?.scrollTo({ top: 0 })
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '获取通知失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCategoryChange = (category: 'all' | NotificationCategory) => {
|
||||
activeCategory.value = category
|
||||
void loadNotificationPage(1)
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
void loadNotificationPage(page)
|
||||
}
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
const response = await notificationStore.markAllRead()
|
||||
|
||||
@@ -68,6 +68,18 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px 8px;
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid var(--art-border-color);
|
||||
|
||||
:deep(.el-pagination) {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<ElDialog v-model="visible" title="设置实名认证策略" width="35%">
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
title="设置实名认证策略"
|
||||
width="40%"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">
|
||||
<ElFormItem label="资产标识">
|
||||
<span style="font-weight: bold; color: #409eff">{{ assetIdentifier }}</span>
|
||||
</ElFormItem>
|
||||
@@ -9,7 +13,7 @@
|
||||
{{ getPolicyName(currentPolicy) }}
|
||||
</ElTag>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="实名认证策略" prop="realname_policy">
|
||||
<ElFormItem label="认证策略" prop="realname_policy">
|
||||
<ElRadioGroup v-model="form.realname_policy">
|
||||
<ElRadio value="none">无需实名</ElRadio>
|
||||
<ElRadio value="before_order">先实名后充值/购买</ElRadio>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<ElDialog v-model="visible" title="切换SIM卡" width="600px" @close="handleClose">
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
title="切换SIM卡"
|
||||
width="40%"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-if="loading" style="padding: 40px; text-align: center">
|
||||
<ElIcon class="is-loading" :size="40"><Loading /></ElIcon>
|
||||
<div style="margin-top: 16px">加载设备绑定的卡列表中...</div>
|
||||
@@ -24,7 +29,7 @@
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-width="120px"
|
||||
label-width="90px"
|
||||
>
|
||||
<ElFormItem label="设备信息">
|
||||
<span style="font-weight: bold; color: #409eff">{{ deviceInfo }}</span>
|
||||
|
||||
@@ -3,6 +3,11 @@ export const AUDIT_PERMISSIONS = {
|
||||
centerPage: 'audit:center_view',
|
||||
eventList: 'audit:event_list',
|
||||
eventDetail: 'audit:event_detail',
|
||||
auditEventResourceTimeline: 'audit:audit_event_resource_timeline',
|
||||
auditEventIntegrationDetail: 'audit:audit_event_integration_detail',
|
||||
integrationDetailRequestTimeline: 'audit:integration_detail_request_timeline',
|
||||
integrationDetailCorrelationTimeline: 'audit:integration_detail_correlation_timeline',
|
||||
integrationDetailResourceTimeline: 'audit:integration_detail_resource_timeline',
|
||||
actorTimeline: 'audit:actor_timeline',
|
||||
resourceSearch: 'audit:resource_search',
|
||||
resourceTimeline: 'audit:resource_timeline',
|
||||
@@ -11,16 +16,44 @@ export const AUDIT_PERMISSIONS = {
|
||||
financeTimeline: 'audit:finance_timeline',
|
||||
riskPage: 'audit:risk_view',
|
||||
riskEvents: 'audit:risk_events',
|
||||
riskEventDetail: 'audit:risk_event_detail',
|
||||
integrationPage: 'audit:integration_view',
|
||||
integrationDetail: 'audit:integration_detail',
|
||||
agentActivity: 'audit:agent_resource_activity',
|
||||
enterpriseActivity: 'audit:enterprise_resource_activity',
|
||||
agentCardActivity: 'audit:agent_card_activity',
|
||||
agentDeviceActivity: 'audit:agent_device_activity',
|
||||
enterpriseCardActivity: 'audit:enterprise_card_activity',
|
||||
enterpriseDeviceActivity: 'audit:enterprise_device_activity',
|
||||
agentExchangeActivity: 'audit:agent_exchange_activity',
|
||||
cardEntry: 'audit:card_entry',
|
||||
deviceEntry: 'audit:device_entry',
|
||||
exchangeEntry: 'audit:exchange_entry',
|
||||
exchangeOldAssetEntry: 'audit:exchange_old_asset_entry',
|
||||
exchangeNewAssetEntry: 'audit:exchange_new_asset_entry',
|
||||
agentAssetAllocationActivity: 'audit:agent_asset_allocation_activity',
|
||||
assetAllocationEntry: 'audit:asset_allocation_entry',
|
||||
assetAllocationAssetEntry: 'audit:asset_allocation_asset_entry',
|
||||
assetInfoEntry: 'audit:asset_info_entry',
|
||||
agentAssetInfoCardActivity: 'audit:agent_asset_info_card_activity',
|
||||
agentAssetInfoDeviceActivity: 'audit:agent_asset_info_device_activity',
|
||||
enterpriseAssetInfoCardActivity: 'audit:enterprise_asset_info_card_activity',
|
||||
enterpriseAssetInfoDeviceActivity: 'audit:enterprise_asset_info_device_activity',
|
||||
assetInfoBindingCardEntry: 'audit:asset_info_binding_card_entry',
|
||||
agentAssetInfoBindingCardActivity: 'audit:agent_asset_info_binding_card_activity',
|
||||
enterpriseAssetInfoBindingCardActivity: 'audit:enterprise_asset_info_binding_card_activity',
|
||||
assetInfoWalletFinance: 'audit:asset_info_wallet_finance',
|
||||
assetInfoWalletAsset: 'audit:asset_info_wallet_asset',
|
||||
shopEntry: 'audit:shop_entry',
|
||||
enterpriseEntry: 'audit:enterprise_entry',
|
||||
orderEntry: 'audit:order_entry',
|
||||
orderFinanceEntry: 'audit:order_finance_entry',
|
||||
agentRechargeEntry: 'audit:agent_recharge_entry',
|
||||
agentRechargeFinanceEntry: 'audit:agent_recharge_finance_entry',
|
||||
agentRechargeApprovalEntry: 'audit:agent_recharge_approval_entry',
|
||||
refundEntry: 'audit:refund_entry',
|
||||
refundFinanceEntry: 'audit:refund_finance_entry',
|
||||
refundApprovalEntry: 'audit:refund_approval_entry',
|
||||
walletEntry: 'audit:wallet_entry'
|
||||
} as const
|
||||
|
||||
|
||||
@@ -406,6 +406,14 @@
|
||||
"orderList": "Order List",
|
||||
"orderDetail": "Order Details"
|
||||
},
|
||||
"financialManagement": {
|
||||
"title": "Financial Management",
|
||||
"agentRecharge": "Agent Recharge",
|
||||
"agentRechargeDetail": "Agent Recharge Details",
|
||||
"refundManagement": "Refund Management",
|
||||
"refundDetail": "Refund Details",
|
||||
"agentFundOverview": "Agent Fund Overview"
|
||||
},
|
||||
"deviceManagement": {
|
||||
"title": "Device Management",
|
||||
"devices": "Device Management"
|
||||
|
||||
@@ -408,13 +408,13 @@
|
||||
"agentRecharge": "代理充值",
|
||||
"agentRechargeDetail": "代理充值详情",
|
||||
"refundManagement": "退款管理",
|
||||
"refundDetail": "退款详情"
|
||||
"refundDetail": "退款详情",
|
||||
"agentFundOverview": "代理商资金概况"
|
||||
},
|
||||
"commission": {
|
||||
"title": "佣金管理",
|
||||
"withdrawal": "提现审批",
|
||||
"myCommission": "我的佣金",
|
||||
"agentCommission": "代理商资金概况"
|
||||
"myCommission": "我的佣金"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置管理",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { loadingService } from '@/utils/ui'
|
||||
import { useCommon } from '@/composables/useCommon'
|
||||
import { useWorktabStore } from '@/store/modules/worktab'
|
||||
import { isInWhiteList, hasRoutePermission, isTokenValid, buildLoginRedirect } from './permission'
|
||||
import { isPlatformAuditAccount } from '@/utils/business/auditAccess'
|
||||
|
||||
// 是否已注册动态路由
|
||||
const isRouteRegistered = ref(false)
|
||||
@@ -95,6 +96,14 @@ async function handleRouteGuard(
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
to.path.startsWith('/audit') &&
|
||||
!isPlatformAuditAccount(userStore.info.user_type, userStore.isSuperAdmin)
|
||||
) {
|
||||
next(RoutesAlias.Exception403 || '/exception/403')
|
||||
return
|
||||
}
|
||||
|
||||
// 处理动态路由注册
|
||||
if (!isRouteRegistered.value && userStore.isLogin) {
|
||||
await handleDynamicRoutes(to, router, next)
|
||||
|
||||
@@ -689,6 +689,17 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
isHide: true,
|
||||
keepAlive: false
|
||||
}
|
||||
},
|
||||
// 代理商资金概况
|
||||
{
|
||||
path: 'agent-fund-overview',
|
||||
name: 'AgentFundOverview',
|
||||
component: RoutesAlias.AgentFundOverviewComponent,
|
||||
meta: {
|
||||
title: 'menus.financialManagement.agentFundOverview',
|
||||
keepAlive: true,
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -790,17 +801,6 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
keepAlive: true,
|
||||
roles: ['R_AGENT']
|
||||
}
|
||||
},
|
||||
// 代理商资金概况
|
||||
{
|
||||
path: 'agent-fund-overview',
|
||||
name: 'AgentFundOverview',
|
||||
component: RoutesAlias.AgentFundOverviewComponent,
|
||||
meta: {
|
||||
title: 'menus.commission.agentCommission',
|
||||
keepAlive: true,
|
||||
roles: ['R_SUPER', 'R_ADMIN']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -89,13 +89,13 @@ export enum RoutesAlias {
|
||||
AgentRechargeDetail = '/finance/agent-recharge/detail', // 代理充值详情
|
||||
RefundManagement = '/finance/refund', // 退款管理
|
||||
RefundDetail = '/finance/refund/detail', // 退款详情
|
||||
AgentFundOverview = '/finance/agent-fund-overview', // 代理商资金概况
|
||||
|
||||
ExpiringAssets = '/asset-management/expiring-assets', // 临期资产
|
||||
|
||||
// 佣金管理
|
||||
WithdrawalApproval = '/commission-management/withdrawal-approval', // 提现审批
|
||||
MyCommission = '/commission-management/my-commission', // 我的佣金
|
||||
AgentFundOverview = '/commission/agent-fund-overview', // 代理商资金概况
|
||||
AgentFundOverviewComponent = '/commission-management/agent-fund-overview', // 代理商资金概况组件路径
|
||||
|
||||
// 设置管理
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { NotificationService } from '@/api/modules/notification'
|
||||
import type { NotificationItem, NotificationUnreadSummary } from '@/types/api'
|
||||
import type {
|
||||
BaseResponse,
|
||||
NotificationItem,
|
||||
NotificationListResponse,
|
||||
NotificationUnreadSummary
|
||||
} from '@/types/api'
|
||||
|
||||
const emptySummary = (): NotificationUnreadSummary => ({
|
||||
approval: 0,
|
||||
@@ -16,8 +21,22 @@ export const useNotificationStore = defineStore('notificationStore', () => {
|
||||
const displayCount = ref('0')
|
||||
const summary = ref<NotificationUnreadSummary>(emptySummary())
|
||||
const recentNotifications = ref<NotificationItem[]>([])
|
||||
const notificationPage = ref(1)
|
||||
const notificationPageSize = 10
|
||||
const notificationTotal = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const applyNotificationList = (
|
||||
response: BaseResponse<NotificationListResponse>,
|
||||
fallbackPage: number
|
||||
) => {
|
||||
if (response.code !== 0 || !response.data) return
|
||||
|
||||
recentNotifications.value = response.data.items
|
||||
notificationPage.value = response.data.page || fallbackPage
|
||||
notificationTotal.value = Math.max(0, response.data.total || 0)
|
||||
}
|
||||
|
||||
const refreshUnreadCount = async () => {
|
||||
const response = await NotificationService.getUnreadCount()
|
||||
if (response.code === 0 && response.data) {
|
||||
@@ -27,19 +46,33 @@ export const useNotificationStore = defineStore('notificationStore', () => {
|
||||
return unreadCount.value
|
||||
}
|
||||
|
||||
const refreshSummary = async () => {
|
||||
const loadNotifications = async (page = notificationPage.value) => {
|
||||
const targetPage = Math.max(1, page)
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await NotificationService.getNotifications({
|
||||
page: targetPage,
|
||||
page_size: notificationPageSize
|
||||
})
|
||||
applyNotificationList(response, targetPage)
|
||||
return response
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshSummary = async (page = 1) => {
|
||||
const targetPage = Math.max(1, page)
|
||||
loading.value = true
|
||||
try {
|
||||
const [summaryResponse, listResponse] = await Promise.all([
|
||||
NotificationService.getUnreadSummary(),
|
||||
NotificationService.getNotifications({ page: 1, page_size: 10 })
|
||||
NotificationService.getNotifications({ page: targetPage, page_size: notificationPageSize })
|
||||
])
|
||||
if (summaryResponse.code === 0 && summaryResponse.data) {
|
||||
summary.value = summaryResponse.data
|
||||
}
|
||||
if (listResponse.code === 0 && listResponse.data) {
|
||||
recentNotifications.value = listResponse.data.items.slice(0, 10)
|
||||
}
|
||||
applyNotificationList(listResponse, targetPage)
|
||||
await refreshUnreadCount()
|
||||
return summary.value
|
||||
} finally {
|
||||
@@ -50,7 +83,7 @@ export const useNotificationStore = defineStore('notificationStore', () => {
|
||||
const markRead = async (id: number) => {
|
||||
const response = await NotificationService.markRead(id)
|
||||
if (response.code === 0) {
|
||||
await refreshSummary()
|
||||
await refreshSummary(notificationPage.value)
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -58,7 +91,7 @@ export const useNotificationStore = defineStore('notificationStore', () => {
|
||||
const markAllRead = async (category?: string) => {
|
||||
const response = await NotificationService.markAllRead(category ? { category } : {})
|
||||
if (response.code === 0) {
|
||||
await refreshSummary()
|
||||
await refreshSummary(notificationPage.value)
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -68,8 +101,12 @@ export const useNotificationStore = defineStore('notificationStore', () => {
|
||||
displayCount,
|
||||
summary,
|
||||
recentNotifications,
|
||||
notificationPage,
|
||||
notificationPageSize,
|
||||
notificationTotal,
|
||||
loading,
|
||||
refreshUnreadCount,
|
||||
loadNotifications,
|
||||
refreshSummary,
|
||||
markRead,
|
||||
markAllRead
|
||||
|
||||
@@ -51,7 +51,7 @@ export const useUserStore = defineStore(
|
||||
}
|
||||
|
||||
// 检查是否是超级管理员
|
||||
const isSuperAdmin = computed(() => info.value.user_type === 1)
|
||||
const isSuperAdmin = computed(() => Number(info.value.user_type) === 1)
|
||||
|
||||
// 检查是否有某个权限
|
||||
const hasPermission = (permission: string): boolean => {
|
||||
|
||||
@@ -236,6 +236,8 @@ export interface AssetBoundCard {
|
||||
card_id: number // 卡 ID
|
||||
iccid: string // ICCID
|
||||
msisdn: string // 手机号
|
||||
carrier_type?: string // 运营商类型
|
||||
carrier_name?: string // 运营商名称
|
||||
network_status: NetworkStatus // 网络状态
|
||||
real_name_status: RealNameStatus // 实名状态
|
||||
real_name_at?: string | null // 实名时间
|
||||
@@ -594,51 +596,3 @@ export interface DtoUpdateAssetRealnameStatusResponse {
|
||||
real_name_status: number // 更新后的实名状态 (0:未实名, 1:已实名)
|
||||
real_name_status_name: string // 实名状态名称(中文)
|
||||
}
|
||||
|
||||
// ========== 资产操作审计日志 ==========
|
||||
|
||||
/**
|
||||
* 操作审计日志项
|
||||
*/
|
||||
export interface AssetOperationLogItem {
|
||||
id: number // 日志ID
|
||||
asset_id: number // 资产ID
|
||||
asset_identifier: string // 资产标识符
|
||||
asset_type: string // 资产类型
|
||||
operation_type: string // 操作类型
|
||||
operation_desc: string // 操作描述
|
||||
result_status: 'success' | 'failed' | 'denied' // 执行结果
|
||||
created_at: string // 创建时间
|
||||
operator_id?: number // 操作人ID
|
||||
operator_name: string // 操作人名称
|
||||
operator_type: string // 操作人类型
|
||||
before_data: Record<string, any> // 操作前数据(原始)
|
||||
after_data: Record<string, any> // 操作后数据(原始)
|
||||
operation_content_before?: Record<string, any> // 操作内容(变更前)
|
||||
operation_content_after?: Record<string, any> // 操作内容(变更后)
|
||||
operation_fields_desc?: Record<string, string> // 字段中文说明
|
||||
batch_total: number // 批量总数
|
||||
success_count: number // 成功数量
|
||||
fail_count: number // 失败数量
|
||||
failed_items?: Array<{ iccid?: string; reason?: string }> // 失败明细
|
||||
reason?: string // 原因说明
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产操作审计日志响应
|
||||
*/
|
||||
export interface AssetOperationLogsResponse {
|
||||
items: AssetOperationLogItem[] // 日志列表
|
||||
page: number // 当前页码
|
||||
page_size: number // 每页数量
|
||||
total: number // 总记录数
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产操作审计日志查询参数
|
||||
*/
|
||||
export interface AssetOperationLogsParams {
|
||||
page?: number // 页码,默认1
|
||||
page_size?: number // 每页条数,默认20,最大100
|
||||
result_status?: 'success' | 'failed' | 'denied' // 执行结果
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ export interface MyCommissionSummary {
|
||||
frozen_commission: number // 冻结中佣金(分)
|
||||
withdrawing_commission: number // 提现中佣金(分)
|
||||
withdrawn_commission: number // 已提现佣金(分)
|
||||
unwithdraw_commission?: number // 未提现佣金(分)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,6 +238,7 @@ export type ShopCommissionSummaryItem = ShopFundSummaryItem
|
||||
* 代理商资金汇总查询参数(原佣金汇总查询参数)
|
||||
*/
|
||||
export interface ShopFundSummaryQueryParams extends PaginationParams {
|
||||
shop_id?: number // 店铺ID
|
||||
shop_name?: string // 店铺名称
|
||||
username?: string // 用户名
|
||||
}
|
||||
|
||||
@@ -325,6 +325,60 @@ export interface ShopSeriesGrantQueryParams extends PaginationParams {
|
||||
status?: number // 状态筛选
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理系列授权套餐候选项
|
||||
*/
|
||||
export interface ShopSeriesGrantPackageOption {
|
||||
authorized: boolean
|
||||
id: number
|
||||
package_code: string
|
||||
package_name: string
|
||||
package_type: 'formal' | 'addon'
|
||||
series_id: number | null
|
||||
series_name: string | null
|
||||
is_gift: boolean
|
||||
status: number
|
||||
status_name: string
|
||||
shelf_status: number
|
||||
shelf_status_name: string
|
||||
calendar_type: 'natural_month' | 'by_day'
|
||||
duration_days: number | null
|
||||
duration_months: number
|
||||
real_data_mb: number
|
||||
virtual_data_mb: number
|
||||
enable_virtual_data: boolean
|
||||
virtual_ratio: number
|
||||
data_reset_cycle: string
|
||||
cost_price: number
|
||||
retail_price: number | null
|
||||
suggested_retail_price: number | null
|
||||
effective_retail_price: number | null
|
||||
expiry_base: string
|
||||
expiry_base_override: PackageAllocationExpiryBaseOverride | null
|
||||
expiry_base_override_name: string
|
||||
default_expiry_base: string
|
||||
default_expiry_base_name: string
|
||||
effective_expiry_base: string
|
||||
effective_expiry_base_name: string
|
||||
price_config_status: number
|
||||
price_config_status_name: string
|
||||
retail_price_config_status: number | null
|
||||
current_commission_rate: string
|
||||
profit_margin: number | null
|
||||
one_time_commission_amount: number | null
|
||||
tier_info?: {
|
||||
current_rate?: string
|
||||
next_rate?: string
|
||||
next_threshold?: number | null
|
||||
}
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ShopSeriesGrantPackageOptionsResponse {
|
||||
items: ShopSeriesGrantPackageOption[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建代理系列授权请求
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,19 @@ export enum RefundStatus {
|
||||
RETURNED = 4 // 已退回
|
||||
}
|
||||
|
||||
// 审批状态
|
||||
export enum RefundApprovalStatus {
|
||||
SUBMITTING = 0, // 提交中
|
||||
APPROVING = 1, // 审批中
|
||||
APPROVED = 2, // 已通过
|
||||
REJECTED = 3, // 已拒绝
|
||||
REVOKED = 4, // 已撤销
|
||||
REVOKED_AFTER_APPROVED = 5, // 通过后撤销
|
||||
DELETED = 6, // 已删除
|
||||
SUBMIT_FAILED = 7, // 提交失败
|
||||
RESULT_UNKNOWN = 8 // 提交结果未知
|
||||
}
|
||||
|
||||
export interface RefundAttachment {
|
||||
file_key: string
|
||||
file_name?: string
|
||||
@@ -70,7 +83,7 @@ export interface Refund {
|
||||
approval_provider?: 'wecom' | null
|
||||
approval_instance_id?: number | null
|
||||
approval_source?: 'none' | 'legacy' | 'wecom' | null // 审批来源
|
||||
approval_status?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | null // 审批状态
|
||||
approval_status?: RefundApprovalStatus | null // 审批状态
|
||||
approval_status_name?: string | null // 审批状态名称
|
||||
current_approver_summary?: string | null // 当前审批人摘要
|
||||
processing_status?: string | null // 业务处理状态
|
||||
|
||||
2
src/types/auto-imports.d.ts
vendored
2
src/types/auto-imports.d.ts
vendored
@@ -7,8 +7,6 @@
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const ElButton: (typeof import('element-plus/es'))['ElButton']
|
||||
const ElMessage: typeof import('element-plus/es')['ElMessage']
|
||||
const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
||||
|
||||
3
src/types/components.d.ts
vendored
3
src/types/components.d.ts
vendored
@@ -99,6 +99,8 @@ declare module 'vue' {
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
@@ -146,7 +148,6 @@ declare module 'vue' {
|
||||
LoginLeftView: typeof import('./../components/core/views/login/LoginLeftView.vue')['default']
|
||||
MenuLayoutSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue')['default']
|
||||
MenuStyleSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue')['default']
|
||||
OperationLogsDialog: typeof import('./../components/business/OperationLogsDialog.vue')['default']
|
||||
OperatorSelect: typeof import('./../components/business/OperatorSelect.vue')['default']
|
||||
PackageSelector: typeof import('./../components/business/PackageSelector.vue')['default']
|
||||
PaymentVoucherDialog: typeof import('./../components/business/PaymentVoucherDialog.vue')['default']
|
||||
|
||||
@@ -59,7 +59,7 @@ export const strongPasswordRules = (t: (key: string) => string): FormItemRule[]
|
||||
trigger: 'blur'
|
||||
},
|
||||
{
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
validator: (_rule: any, value: any, callback: any) => {
|
||||
if (!value) {
|
||||
callback()
|
||||
return
|
||||
@@ -93,7 +93,7 @@ export const confirmPasswordRules = (
|
||||
trigger: 'blur'
|
||||
},
|
||||
{
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
validator: (_rule: any, value: any, callback: any) => {
|
||||
if (!value) {
|
||||
callback()
|
||||
return
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export type ApiErrorKind = 'auth' | 'forbidden' | 'validation' | 'conflict' | 'timeout' | 'server' | 'unknown'
|
||||
export type ApiErrorKind =
|
||||
| 'auth'
|
||||
| 'forbidden'
|
||||
| 'validation'
|
||||
| 'conflict'
|
||||
| 'timeout'
|
||||
| 'server'
|
||||
| 'unknown'
|
||||
|
||||
export interface NormalizedApiError {
|
||||
kind: ApiErrorKind
|
||||
@@ -20,12 +27,59 @@ export const normalizeApiError = (error: unknown): NormalizedApiError => {
|
||||
const timeout = axios.isAxiosError(error) && error.code === 'ECONNABORTED'
|
||||
|
||||
if (timeout) {
|
||||
return { kind: 'timeout', status, code, message: '请求超时,结果可能未知,请刷新确认', unknownResult: true }
|
||||
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
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ export const getApprovalStatusText = (summary: ApprovalSummary): string => {
|
||||
summary.approval_provider === 'wecom' ||
|
||||
summary.approval_instance_id
|
||||
) {
|
||||
return summary.approval_status_name || approvalStatusNames[Number(summary.approval_status)] || '-'
|
||||
return (
|
||||
summary.approval_status_name || approvalStatusNames[Number(summary.approval_status)] || '-'
|
||||
)
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
@@ -27,7 +29,8 @@ export const getCurrentApproverSummaryText = (summary: ApprovalSummary): string
|
||||
summary.approval_source !== 'wecom' &&
|
||||
summary.approval_provider !== 'wecom' &&
|
||||
!summary.approval_instance_id
|
||||
) return '-'
|
||||
)
|
||||
return '-'
|
||||
return summary.current_approver_summary || '-'
|
||||
}
|
||||
|
||||
|
||||
14
src/utils/business/auditAccess.ts
Normal file
14
src/utils/business/auditAccess.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { AuditInvestigationTarget } from '@/components/business/audit/types'
|
||||
|
||||
export const isPlatformAuditAccount = (userType?: number | null, isSuperAdmin = false) =>
|
||||
isSuperAdmin || [1, 2].includes(Number(userType))
|
||||
|
||||
export const canLoadAuditInvestigation = (
|
||||
target: AuditInvestigationTarget,
|
||||
userType?: number | null,
|
||||
isSuperAdmin = false
|
||||
) => {
|
||||
if (target.mode === 'agent') return Number(userType) === 3
|
||||
if (target.mode === 'enterprise') return Number(userType) === 4
|
||||
return isPlatformAuditAccount(userType, isSuperAdmin)
|
||||
}
|
||||
@@ -25,7 +25,10 @@ export const getPaymentMerchantId = (
|
||||
}
|
||||
|
||||
export const getPaymentNotifyUrl = (
|
||||
config: Pick<PaymentSettings, 'provider_type' | 'wx_notify_url' | 'fy_notify_url' | 'ali_notify_url'>
|
||||
config: Pick<
|
||||
PaymentSettings,
|
||||
'provider_type' | 'wx_notify_url' | 'fy_notify_url' | 'ali_notify_url'
|
||||
>
|
||||
): string => {
|
||||
if (config.provider_type === 'wechat' || config.provider_type === 'wechat_v2') {
|
||||
return config.wx_notify_url || '-'
|
||||
|
||||
@@ -7,7 +7,7 @@ import { normalizeApiError } from '@/utils/business/apiError'
|
||||
import { syncCompatibleIdFields } from '@/utils/business/id'
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
timeout: 15000, // 请求超时时间(毫秒)
|
||||
timeout: 90000, // 请求超时时间(毫秒)
|
||||
baseURL: import.meta.env.DEV ? '' : import.meta.env.VITE_API_URL, // 开发服务器使用代理,其他模式使用完整URL
|
||||
withCredentials: false, // 异步请求携带cookie
|
||||
transformRequest: [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!--suppress ALL -->
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="account-page" id="table-full-screen">
|
||||
@@ -337,11 +338,14 @@
|
||||
} from '@/config/constants'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
|
||||
import { isPlatformAuditAccount } from '@/utils/business/auditAccess'
|
||||
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
defineOptions({ name: 'Account' }) // 定义组件名称,用于 KeepAlive 缓存控制
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
const canModifyAccountStatus = hasAuth('account:modify_status')
|
||||
const route = useRoute()
|
||||
|
||||
@@ -620,7 +624,8 @@
|
||||
{
|
||||
prop: 'enterprise_name',
|
||||
label: '企业名称',
|
||||
minWidth: 150,
|
||||
minWidth: 200,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: any) => {
|
||||
return row.enterprise_name || '-'
|
||||
}
|
||||
@@ -673,8 +678,12 @@
|
||||
const getActions = (row: any) => {
|
||||
const actions: any[] = []
|
||||
|
||||
if (hasAuth(AUDIT_PERMISSIONS.resourceTimeline)) {
|
||||
if (
|
||||
isPlatformAuditAccount(userStore.info.user_type, userStore.isSuperAdmin) &&
|
||||
hasAuth(AUDIT_PERMISSIONS.resourceTimeline)
|
||||
) {
|
||||
const auditTarget = resolveAuditResourceTarget({
|
||||
userType: userStore.info.user_type,
|
||||
resourceType: 'account',
|
||||
internalId: row.id
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #left>
|
||||
<!--suppress VueUnrecognizedDirective -->
|
||||
<ElButton @click="showDialog('add')" v-permission="'enterprise_customer:add'"
|
||||
>新增企业客户</ElButton
|
||||
>
|
||||
@@ -439,7 +440,7 @@
|
||||
{
|
||||
prop: 'contact_name',
|
||||
label: '联系人姓名',
|
||||
width: 120
|
||||
width: 140
|
||||
},
|
||||
{
|
||||
prop: 'contact_phone',
|
||||
@@ -505,16 +506,19 @@
|
||||
const getActions = (row: EnterpriseItem) => {
|
||||
const actions: any[] = []
|
||||
|
||||
if (hasAuth(AUDIT_PERMISSIONS.enterpriseEntry)) {
|
||||
const userType = Number(userStore.getUserInfo.user_type)
|
||||
const auditPermission =
|
||||
userType === 3 ? AUDIT_PERMISSIONS.agentActivity : AUDIT_PERMISSIONS.enterpriseEntry
|
||||
if (hasAuth(auditPermission)) {
|
||||
const auditTarget = resolveAuditResourceTarget({
|
||||
userType: userStore.getUserInfo.user_type,
|
||||
userType,
|
||||
resourceType: 'enterprise',
|
||||
internalId: row.id,
|
||||
businessIdentifier: row.enterprise_code
|
||||
})
|
||||
if (auditTarget)
|
||||
actions.push({
|
||||
label: '审计记录',
|
||||
label: userType === 3 ? '活动记录' : '审计记录',
|
||||
handler: () => openAuditInvestigation(auditTarget),
|
||||
type: 'primary'
|
||||
})
|
||||
@@ -804,7 +808,7 @@
|
||||
|
||||
dialogVisible.value = false
|
||||
formEl.resetFields()
|
||||
getTableData()
|
||||
await getTableData()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
<template>
|
||||
<ElCard shadow="never" class="search-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>资产查询</span>
|
||||
<ElButton type="danger" @click="handleGoBack">返回上次搜索</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
<div class="search-area">
|
||||
<div class="iccid-search">
|
||||
<div class="iccid-input-wrapper">
|
||||
<ElInput
|
||||
v-model="searchIccid"
|
||||
placeholder="请输入虚拟号、ICCID、IMEI、SN或MSISDN"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
class="asset-search-input"
|
||||
@focus="iccidInputFocused = true"
|
||||
@blur="iccidInputFocused = false"
|
||||
@@ -42,16 +37,17 @@
|
||||
>
|
||||
同步
|
||||
</ElButton>
|
||||
<ElButton type="default" plain :icon="Back" @click="handleGoBack"> 返回上次搜索 </ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElCard, ElInput, ElButton } from 'element-plus'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { ElInput, ElButton } from 'element-plus'
|
||||
import { Back, Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { useAssetFormatters } from '../composables/useAssetFormatters'
|
||||
|
||||
// Use formatters
|
||||
@@ -135,103 +131,87 @@
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-card {
|
||||
.search-area {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
margin-bottom: 20px;
|
||||
overflow: visible;
|
||||
background: var(--el-bg-color, #fff);
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
padding: 16px 20px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
.iccid-search {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
overflow: visible;
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
.iccid-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1 1 360px;
|
||||
min-width: 240px;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px 20px;
|
||||
overflow: visible;
|
||||
}
|
||||
.asset-search-input {
|
||||
width: 100%;
|
||||
|
||||
.iccid-search {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
|
||||
.iccid-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1 1 360px;
|
||||
min-width: 240px;
|
||||
|
||||
.asset-search-input {
|
||||
width: 100%;
|
||||
:deep(.el-input__prefix) {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.iccid-magnifier {
|
||||
.iccid-magnifier {
|
||||
position: absolute;
|
||||
top: calc(100% + 12px);
|
||||
left: 0;
|
||||
z-index: 2000;
|
||||
white-space: nowrap;
|
||||
|
||||
.magnifier-arrow {
|
||||
position: absolute;
|
||||
top: calc(100% + 12px);
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
top: -9px;
|
||||
left: 80px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-top: 2px solid var(--el-color-primary);
|
||||
border-left: 2px solid var(--el-color-primary);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
// 三角箭头 - 上边框样式
|
||||
.magnifier-arrow {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
left: 80px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: var(--el-bg-color, #fff);
|
||||
border-top: 2px solid var(--el-color-primary);
|
||||
border-left: 2px solid var(--el-color-primary);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
// 内容区域
|
||||
.magnifier-content {
|
||||
max-width: min(720px, calc(100vw - 48px));
|
||||
padding: 16px 24px;
|
||||
font-family: 'Courier New', Consolas, monospace;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--el-text-color-primary);
|
||||
letter-spacing: 3px;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
background-color: var(--el-bg-color, #fff);
|
||||
border: 2px solid var(--el-color-primary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||
}
|
||||
.magnifier-content {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
max-width: min(720px, calc(100vw - 48px));
|
||||
padding: 14px 22px;
|
||||
font-family: 'Courier New', Consolas, monospace;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--el-color-primary);
|
||||
text-align: center;
|
||||
letter-spacing: 1.5px;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border: 2px solid var(--el-color-primary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 18px rgb(15 23 42 / 12%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.operation-button-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.operation-button-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// 放大效果的过渡动画
|
||||
.zoom-fade-enter-active,
|
||||
.zoom-fade-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.zoom-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translateY(-10px);
|
||||
}
|
||||
|
||||
.zoom-fade-enter-from,
|
||||
.zoom-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translateY(-10px);
|
||||
@@ -244,66 +224,47 @@
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.search-card {
|
||||
top: 16px;
|
||||
.search-area {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.iccid-search {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.iccid-input-wrapper {
|
||||
flex-basis: auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.el-card__header) .card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
.iccid-magnifier {
|
||||
right: 0;
|
||||
left: 0;
|
||||
white-space: normal;
|
||||
|
||||
.el-button {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
.magnifier-arrow {
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.iccid-search {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.iccid-input-wrapper {
|
||||
flex-basis: auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
.iccid-magnifier {
|
||||
right: 0;
|
||||
left: 0;
|
||||
white-space: normal;
|
||||
|
||||
.magnifier-arrow {
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.magnifier-content {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
font-size: 18px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.magnifier-content {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
font-size: 18px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-action-button,
|
||||
.operation-button-group,
|
||||
.operation-button-group .el-button {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
.search-action-button,
|
||||
.operation-button-group,
|
||||
.operation-button-group .el-button {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.operation-button-group {
|
||||
margin-left: 0;
|
||||
}
|
||||
.operation-button-group {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,8 @@
|
||||
<template>
|
||||
<ElCard shadow="never" class="info-card current-package-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">当前生效套餐</span>
|
||||
<div class="header-actions">
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="current-package-section">
|
||||
<ElDivider class="info-section-divider" content-position="left">
|
||||
<span class="section-divider-title">当前生效套餐</span>
|
||||
</ElDivider>
|
||||
|
||||
<!-- 绑定设备提示(优先于一切内容) -->
|
||||
<template v-if="props.boundDeviceId">
|
||||
@@ -36,7 +31,7 @@
|
||||
<template v-if="isAdminOrPlatform">
|
||||
<div class="flow-stats-container">
|
||||
<!-- 真流量 -->
|
||||
<div v-if="canViewCurrentPackageRealUsage" class="flow-stat-section">
|
||||
<div v-if="canShowCurrentPackageRealUsage" class="flow-stat-section">
|
||||
<div class="flow-stat-label">真流量使用</div>
|
||||
<div class="flow-stat-row">
|
||||
<span class="stat-item">
|
||||
@@ -59,28 +54,49 @@
|
||||
)
|
||||
}}</span>
|
||||
</span>
|
||||
<span v-if="canShowCurrentPackageVirtualUsage" class="stat-item">
|
||||
<span class="stat-label">停机阈值</span>
|
||||
<span class="stat-value">{{
|
||||
formatDataSize(currentPackage.virtual_total_mb || 0)
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress-wrapper">
|
||||
<div class="progress-track">
|
||||
<ElProgress
|
||||
:percentage="
|
||||
getUsageProgress(
|
||||
currentPackage.real_used_mb || 0,
|
||||
currentPackage.real_total_mb || 0
|
||||
)
|
||||
"
|
||||
:color="
|
||||
getProgressColorByPercentage(
|
||||
getUsageProgress(
|
||||
currentPackage.real_used_mb || 0,
|
||||
currentPackage.real_total_mb || 0
|
||||
)
|
||||
)
|
||||
"
|
||||
:stroke-width="10"
|
||||
/>
|
||||
<div
|
||||
v-if="canShowCurrentPackageVirtualUsage"
|
||||
class="breakpoint-marker"
|
||||
:style="
|
||||
getBreakpointMarkerStyle(
|
||||
currentPackage.virtual_total_mb || 0,
|
||||
currentPackage.real_total_mb || 0
|
||||
)
|
||||
"
|
||||
:title="`停机阈值/真流量总量: ${formatDataSize(currentPackage.virtual_total_mb || 0)} / ${formatDataSize(currentPackage.real_total_mb || 0)}`"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<ElProgress
|
||||
:percentage="
|
||||
getUsageProgress(
|
||||
currentPackage.real_used_mb || 0,
|
||||
currentPackage.real_total_mb || 0
|
||||
)
|
||||
"
|
||||
:color="
|
||||
getProgressColorByPercentage(
|
||||
getUsageProgress(
|
||||
currentPackage.real_used_mb || 0,
|
||||
currentPackage.real_total_mb || 0
|
||||
)
|
||||
)
|
||||
"
|
||||
:stroke-width="10"
|
||||
/>
|
||||
</div>
|
||||
<!-- 虚流量 -->
|
||||
<div v-if="canShowCurrentPackageVirtualUsage" class="flow-stat-section">
|
||||
<div class="flow-stat-label">虚流量使用</div>
|
||||
<div class="flow-stat-label">流量用量</div>
|
||||
<div class="flow-stat-row">
|
||||
<span class="stat-item">
|
||||
<span class="stat-label">已使用</span>
|
||||
@@ -89,9 +105,17 @@
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-label">停机阈值</span>
|
||||
<span class="stat-label">总量</span>
|
||||
<span class="stat-value">{{
|
||||
formatDataSize(currentPackage.virtual_total_mb || 0)
|
||||
formatDataSize(currentPackage.real_total_mb || 0)
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-label">剩余</span>
|
||||
<span class="stat-value">{{
|
||||
formatDataSize(
|
||||
(currentPackage.real_total_mb || 0) - (currentPackage.virtual_used_mb || 0)
|
||||
)
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -115,16 +139,6 @@
|
||||
"
|
||||
:stroke-width="10"
|
||||
/>
|
||||
<div
|
||||
class="breakpoint-marker"
|
||||
:style="
|
||||
getBreakpointMarkerStyle(
|
||||
currentPackage.virtual_total_mb || 0,
|
||||
currentPackage.real_total_mb || 0
|
||||
)
|
||||
"
|
||||
:title="`停机阈值/真流量总量: ${formatDataSize(currentPackage.virtual_total_mb || 0)} / ${formatDataSize(currentPackage.real_total_mb || 0)}`"
|
||||
></div>
|
||||
</div>
|
||||
<span class="progress-percentage">
|
||||
{{
|
||||
@@ -186,35 +200,73 @@
|
||||
</div>
|
||||
|
||||
<!-- 套餐详情表格 -->
|
||||
<ElDescriptions :column="descriptionsColumn" border class="package-info-table">
|
||||
<ElDescriptionsItem label="订单号">
|
||||
<ElDescriptions
|
||||
class="asset-descriptions"
|
||||
:column="descriptionsColumn"
|
||||
direction="vertical"
|
||||
border
|
||||
size="large"
|
||||
>
|
||||
<ElDescriptionsItem
|
||||
label="订单号"
|
||||
label-class-name="asset-label asset-label--package-order"
|
||||
>
|
||||
{{ currentPackage.order_no || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="套餐名称">
|
||||
<ElDescriptionsItem
|
||||
label="套餐名称"
|
||||
label-class-name="asset-label asset-label--package-name"
|
||||
>
|
||||
{{ currentPackage.package_name || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="套餐成本价格" v-if="isAdminOrPlatform">
|
||||
<ElDescriptionsItem
|
||||
label="套餐成本价格"
|
||||
label-class-name="asset-label asset-label--package-cost"
|
||||
v-if="isAdminOrPlatform"
|
||||
>
|
||||
{{ formatAmount(currentPackage.paid_amount || 0) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="套餐零售价">
|
||||
<ElDescriptionsItem
|
||||
label="套餐零售价"
|
||||
label-class-name="asset-label asset-label--package-retail"
|
||||
>
|
||||
{{ formatAmount(currentPackage.retail_amount || 0) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="套餐类型">
|
||||
<ElDescriptionsItem
|
||||
label="套餐类型"
|
||||
label-class-name="asset-label asset-label--package-type"
|
||||
>
|
||||
{{ getPackageTypeName(currentPackage.package_type) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="开始时间">
|
||||
<ElDescriptionsItem
|
||||
label="开始时间"
|
||||
label-class-name="asset-label asset-label--package-start"
|
||||
>
|
||||
{{ formatDateTime(currentPackage.start_time) || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="到期时间">
|
||||
<ElDescriptionsItem
|
||||
label="到期时间"
|
||||
label-class-name="asset-label asset-label--package-expire"
|
||||
>
|
||||
{{ formatDateTime(currentPackage.expire_time) || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="下单时间">
|
||||
<ElDescriptionsItem
|
||||
label="下单时间"
|
||||
label-class-name="asset-label asset-label--package-created"
|
||||
>
|
||||
{{ formatDateTime(currentPackage.created_at) || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem v-if="currentPackage.duration_days" label="套餐时长">
|
||||
<ElDescriptionsItem
|
||||
v-if="currentPackage.duration_days"
|
||||
label="套餐时长"
|
||||
label-class-name="asset-label asset-label--package-duration"
|
||||
>
|
||||
{{ currentPackage.duration_days }}天
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="到期计时基准">
|
||||
<ElDescriptionsItem
|
||||
label="到期计时基准"
|
||||
label-class-name="asset-label asset-label--package-base"
|
||||
>
|
||||
{{ getExpiryBaseLabel(currentPackage.expiry_base) }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
@@ -222,12 +274,12 @@
|
||||
|
||||
<!-- 空状态 -->
|
||||
<ElEmpty v-else-if="!loading" :description="errorMsg || '暂无当前生效套餐'" :image-size="100" />
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ElCard,
|
||||
ElDivider,
|
||||
ElButton,
|
||||
ElDescriptions,
|
||||
ElDescriptionsItem,
|
||||
@@ -284,19 +336,23 @@
|
||||
const canViewCurrentPackageVirtualUsage = computed(() =>
|
||||
hasAuth('asset_info:view_current_package_virtual_usage')
|
||||
)
|
||||
const canShowCurrentPackageRealUsage = computed(
|
||||
() =>
|
||||
canViewCurrentPackageRealUsage.value || props.currentPackage?.enable_virtual_data === false
|
||||
)
|
||||
const canShowCurrentPackageVirtualUsage = computed(
|
||||
() =>
|
||||
canViewCurrentPackageVirtualUsage.value && props.currentPackage?.enable_virtual_data !== false
|
||||
)
|
||||
const shouldShowTrafficCard = computed(() => {
|
||||
if (!isAdminOrPlatform.value) return true
|
||||
return canViewCurrentPackageRealUsage.value || canShowCurrentPackageVirtualUsage.value
|
||||
return canShowCurrentPackageRealUsage.value || canShowCurrentPackageVirtualUsage.value
|
||||
})
|
||||
|
||||
const descriptionsColumn = computed(() => {
|
||||
if (width.value <= 640) return 1
|
||||
if (width.value <= 1024) return 2
|
||||
return 4
|
||||
return 5
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -322,16 +378,113 @@
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.current-package-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
.current-package-section {
|
||||
.asset-descriptions {
|
||||
:deep(.el-descriptions__label.is-vertical-label) {
|
||||
padding: 8px 15px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background-color: var(--el-fill-color-extra-light);
|
||||
}
|
||||
|
||||
:deep(.asset-label::before) {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
font-family: iconfont-sys, sans-serif !important;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
vertical-align: -3px;
|
||||
color: var(--el-color-primary);
|
||||
content: '';
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
:deep(.asset-label--package-order::before) {
|
||||
content: '\e70f';
|
||||
}
|
||||
|
||||
:deep(.asset-label--package-name::before) {
|
||||
content: '\e775';
|
||||
}
|
||||
|
||||
:deep(.asset-label--package-cost::before) {
|
||||
color: var(--el-color-warning);
|
||||
content: '\e815';
|
||||
}
|
||||
|
||||
:deep(.asset-label--package-retail::before) {
|
||||
color: var(--el-color-success);
|
||||
content: '\e6f5';
|
||||
}
|
||||
|
||||
:deep(.asset-label--package-type::before) {
|
||||
content: '\e723';
|
||||
}
|
||||
|
||||
:deep(.asset-label--package-start::before) {
|
||||
content: '\e751';
|
||||
}
|
||||
|
||||
:deep(.asset-label--package-expire::before) {
|
||||
content: '\e76a';
|
||||
}
|
||||
|
||||
:deep(.asset-label--package-created::before) {
|
||||
content: '\e6ce';
|
||||
}
|
||||
|
||||
:deep(.asset-label--package-duration::before) {
|
||||
content: '\e7af';
|
||||
}
|
||||
|
||||
:deep(.asset-label--package-base::before) {
|
||||
content: '\e7b1';
|
||||
}
|
||||
|
||||
:deep(.el-descriptions__content.is-vertical-content) {
|
||||
padding: 8px 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 23px;
|
||||
color: var(--el-text-color-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.info-section-divider {
|
||||
display: flex;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
border-top: 0;
|
||||
|
||||
:deep(.el-divider__text) {
|
||||
position: static;
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
color: var(--el-text-color-primary);
|
||||
background-color: transparent;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.section-divider-title {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
&::before {
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
content: '';
|
||||
background-color: var(--el-color-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,18 +603,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.package-info-table {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:deep(.el-descriptions__body) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:deep(.el-descriptions__cell) {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (width <= 992px) {
|
||||
.flow-progress-card {
|
||||
.flow-stats-container {
|
||||
@@ -472,15 +613,6 @@
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.flow-progress-card {
|
||||
padding: 14px;
|
||||
|
||||
|
||||
@@ -1,692 +0,0 @@
|
||||
<template>
|
||||
<ElCard shadow="never" class="info-card operation-logs-card" v-loading="loading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="header-title">操作审计日志</span>
|
||||
<div class="filter-section">
|
||||
<ElSelect
|
||||
v-model="filterForm.result_status"
|
||||
placeholder="执行结果"
|
||||
clearable
|
||||
class="result-status-select"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<ElOption label="成功" value="success" />
|
||||
<ElOption label="失败" value="failed" />
|
||||
<ElOption label="拒绝" value="denied" />
|
||||
</ElSelect>
|
||||
<ElButton type="primary" @click="handleQuery">查询</ElButton>
|
||||
<ElButton @click="handleReset">重置</ElButton>
|
||||
<ElTag type="info" size="small">共 {{ total }} 条</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="logs-table-wrapper">
|
||||
<ElTable :data="logList" class="logs-table" border max-height="400">
|
||||
<ElTableColumn label="操作时间" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作类型" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="getOperationTypeTag(row.operation_type)" size="small">
|
||||
{{ row.operation_desc }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作人" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.operator_name || '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作人类型" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ getOperatorTypeText(row) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="执行结果" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="getResultStatusTag(row.result_status)" size="small">
|
||||
{{ getResultStatusText(row.result_status) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="变更内容" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div v-if="getDisplayContent(row).length" class="change-content">
|
||||
<span
|
||||
v-for="(item, index) in getDisplayContent(row)"
|
||||
:key="`${item.type}-${item.key}-${index}`"
|
||||
class="change-item"
|
||||
>
|
||||
<span class="field-name">{{ item.fieldName }}</span>
|
||||
<span class="field-value">
|
||||
<template v-if="item.type === 'change'">
|
||||
<span class="new-value">{{ formatFieldValue(item.key, item.afterValue) }}</span>
|
||||
<ElButton
|
||||
v-if="canDownloadLogFile && isDownloadableFileKey(item.key, item.afterValue)"
|
||||
link
|
||||
type="primary"
|
||||
@click.stop="handleDownloadLogFile(item.afterValue)"
|
||||
>
|
||||
下载文件
|
||||
</ElButton>
|
||||
</template>
|
||||
<span v-else :class="['message-value', item.level === 'error' ? 'is-error' : '']">
|
||||
{{ item.message }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<ElPagination
|
||||
class="logs-pagination"
|
||||
v-if="total > 0"
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElPagination,
|
||||
ElSelect,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTag
|
||||
} from 'element-plus'
|
||||
import { AssetService, StorageService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import type { AssetOperationLogItem } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
|
||||
interface Props {
|
||||
assetIdentifier?: string
|
||||
assetType?: string
|
||||
downloadPermission?: string
|
||||
}
|
||||
|
||||
type TagType = 'primary' | 'success' | 'warning' | 'info' | 'danger'
|
||||
type ResultStatus = '' | 'success' | 'failed' | 'denied'
|
||||
|
||||
type OperationLogRow = AssetOperationLogItem & {
|
||||
operator_type_code?: string
|
||||
error_code?: string
|
||||
error_msg?: string
|
||||
request_id?: string
|
||||
request_path?: string
|
||||
request_method?: string
|
||||
ip_address?: string
|
||||
user_agent?: string
|
||||
}
|
||||
|
||||
interface ChangeItem {
|
||||
type: 'change'
|
||||
key: string
|
||||
fieldName: string
|
||||
beforeValue?: any
|
||||
afterValue?: any
|
||||
}
|
||||
|
||||
interface MessageItem {
|
||||
type: 'message'
|
||||
key: string
|
||||
fieldName: string
|
||||
message: string
|
||||
level: 'info' | 'error'
|
||||
}
|
||||
|
||||
type DisplayItem = ChangeItem | MessageItem
|
||||
|
||||
const operationTypeTagMap: Record<string, TagType> = {
|
||||
device_set_wifi: 'primary',
|
||||
device_switch_card: 'success',
|
||||
device_switch_mode: 'success',
|
||||
device_stop: 'danger',
|
||||
device_start: 'success',
|
||||
device_reboot: 'warning',
|
||||
device_reset: 'warning',
|
||||
device_allocate: 'primary',
|
||||
device_recall: 'warning',
|
||||
device_bind_card: 'primary',
|
||||
device_unbind_card: 'warning',
|
||||
card_allocate: 'primary',
|
||||
card_recall: 'warning',
|
||||
card_stop: 'danger',
|
||||
card_start: 'success',
|
||||
card_manual_stop: 'danger',
|
||||
card_manual_start: 'success',
|
||||
card_auto_stop: 'danger',
|
||||
card_auto_start: 'success',
|
||||
card_polling_status: 'info',
|
||||
asset_realname_policy: 'info',
|
||||
asset_realname_status: 'info',
|
||||
asset_polling: 'info',
|
||||
asset_polling_status: 'info',
|
||||
iot_card_import_task_create: 'primary'
|
||||
}
|
||||
|
||||
const operatorTypeMap: Record<string, string> = {
|
||||
system: '系统',
|
||||
admin_user: '平台用户',
|
||||
agent_user: '代理账号',
|
||||
enterprise_user: '企业用户',
|
||||
api_user: 'API用户'
|
||||
}
|
||||
|
||||
const fieldNameMap: Record<string, string> = {
|
||||
asset_type: '资产类型',
|
||||
batch_no: '批次号',
|
||||
card_category: '卡类型',
|
||||
device_imei: '设备IMEI',
|
||||
device_sn: '设备SN',
|
||||
device_virtual_no: '设备虚拟号',
|
||||
device_virtual_nos: '设备虚拟号',
|
||||
enable_polling: '轮询开关',
|
||||
file_key: '文件存储键',
|
||||
first_realname_at: '首次实名时间',
|
||||
iccid: 'ICCID',
|
||||
iccids: 'ICCID',
|
||||
network_status: '网络状态',
|
||||
real_name_status: '实名状态',
|
||||
realname_policy: '实名认证策略',
|
||||
shop_name: '店铺',
|
||||
source_service: '来源服务',
|
||||
source_shop_name: '来源店铺',
|
||||
status: '资产状态',
|
||||
stop_reason: '停机原因',
|
||||
switch_mode: '切卡模式',
|
||||
target_shop_name: '目标店铺',
|
||||
to_shop_name: '目标店铺'
|
||||
}
|
||||
|
||||
const realnamePolicyMap: Record<string, string> = {
|
||||
none: '无需实名',
|
||||
before_order: '先实名后充值/购买',
|
||||
after_order: '先充值/购买后实名'
|
||||
}
|
||||
|
||||
const stopReasonMap: Record<string, string> = {
|
||||
'': '无',
|
||||
no_package: '无可用套餐',
|
||||
carrier_stopped: '运营商停机',
|
||||
not_realname: '未实名'
|
||||
}
|
||||
|
||||
const assetTypeMap: Record<string, string> = {
|
||||
iot_card: 'IoT卡',
|
||||
device: '设备'
|
||||
}
|
||||
|
||||
const cardCategoryMap: Record<string, string> = {
|
||||
normal: '普通卡',
|
||||
industry: '行业卡'
|
||||
}
|
||||
|
||||
const assetStatusMap: Record<string, string> = {
|
||||
'1': '在库',
|
||||
'2': '已分销',
|
||||
'3': '已激活',
|
||||
'4': '已停用'
|
||||
}
|
||||
|
||||
const newStatusMap: Record<string, string> = {
|
||||
allocated: '已分配',
|
||||
recalled: '已回收'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const loading = ref(false)
|
||||
const logList = ref<OperationLogRow[]>([])
|
||||
const total = ref(0)
|
||||
const canDownloadLogFile = computed(() => {
|
||||
return props.downloadPermission ? hasAuth(props.downloadPermission) : false
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20
|
||||
})
|
||||
|
||||
const filterForm = reactive({
|
||||
result_status: '' as ResultStatus
|
||||
})
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!props.assetIdentifier) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await AssetService.getOperationLogs(props.assetIdentifier, {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
result_status: filterForm.result_status || undefined
|
||||
})
|
||||
if (res.code === 0 && res.data) {
|
||||
logList.value = res.data.items || []
|
||||
total.value = res.data.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载操作日志失败:', error)
|
||||
ElMessage.error('加载操作日志失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuery = () => {
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
filterForm.result_status = ''
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
const handlePageChange = () => {
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
const handleSizeChange = () => {
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
const getOperationTypeTag = (type: string): TagType => {
|
||||
return operationTypeTagMap[type] || 'info'
|
||||
}
|
||||
|
||||
const getResultStatusTag = (status: string): TagType => {
|
||||
const tagMap: Record<string, TagType> = {
|
||||
success: 'success',
|
||||
failed: 'danger',
|
||||
denied: 'warning'
|
||||
}
|
||||
return tagMap[status] || 'info'
|
||||
}
|
||||
|
||||
const getResultStatusText = (status: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
denied: '拒绝'
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
const isDownloadableFileKey = (key: string, value: unknown) => {
|
||||
return key === 'file_key' && typeof value === 'string' && value.trim() !== ''
|
||||
}
|
||||
|
||||
const handleDownloadLogFile = async (fileKey: string) => {
|
||||
try {
|
||||
await StorageService.downloadFileByKey(fileKey)
|
||||
ElMessage.success('文件下载已开始')
|
||||
} catch (error) {
|
||||
console.error('下载日志文件失败:', error)
|
||||
ElMessage.error('下载文件失败')
|
||||
}
|
||||
}
|
||||
|
||||
const getOperatorTypeText = (row: OperationLogRow) => {
|
||||
if (row.operator_type_code && operatorTypeMap[row.operator_type_code]) {
|
||||
return operatorTypeMap[row.operator_type_code]
|
||||
}
|
||||
if (row.operator_type && operatorTypeMap[row.operator_type]) {
|
||||
return operatorTypeMap[row.operator_type]
|
||||
}
|
||||
return row.operator_type || row.operator_type_code || '-'
|
||||
}
|
||||
|
||||
const formatValue = (value: any): string => {
|
||||
if (value === null || value === undefined) return '空'
|
||||
if (typeof value === 'boolean') return value ? '是' : '否'
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value === 'string') return value || '空'
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return '空'
|
||||
if (value.every((item) => ['string', 'number', 'boolean'].includes(typeof item))) {
|
||||
return value.map((item) => formatValue(item)).join('、')
|
||||
}
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const formatFieldValue = (key: string, value: any): string => {
|
||||
if (typeof value === 'string' && (key.endsWith('_at') || key.endsWith('_time'))) {
|
||||
return formatDateTime(value)
|
||||
}
|
||||
if (key === 'enable_polling') return value ? '开启' : '关闭'
|
||||
if (key === 'realname_policy') return realnamePolicyMap[value] || formatValue(value)
|
||||
if (key === 'real_name_status') return value === 1 ? '已实名' : '未实名'
|
||||
if (key === 'switch_mode') {
|
||||
return String(value) === '0' ? '自动' : String(value) === '1' ? '手动' : formatValue(value)
|
||||
}
|
||||
if (key === 'new_status') return newStatusMap[value] || formatValue(value)
|
||||
if (key === 'network_status') {
|
||||
return Number(value) === 1 ? '正常' : Number(value) === 0 ? '停机' : formatValue(value)
|
||||
}
|
||||
if (key === 'stop_reason') return stopReasonMap[String(value ?? '')] ?? formatValue(value)
|
||||
if (key === 'asset_type') return assetTypeMap[String(value)] || formatValue(value)
|
||||
if (key === 'card_category') return cardCategoryMap[String(value)] || formatValue(value)
|
||||
if (key === 'status') return assetStatusMap[String(value)] || formatValue(value)
|
||||
if (key === 'source_service') return value === 'asset_polling' ? '资产轮询' : formatValue(value)
|
||||
return formatValue(value)
|
||||
}
|
||||
|
||||
const sanitizeFieldName = (fieldName: string): string => {
|
||||
return fieldName
|
||||
.trim()
|
||||
.replace(/([^)]*)/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const shouldHideIdField = (key: string, fieldName: string): boolean => {
|
||||
const normalizedKey = key.trim()
|
||||
const normalizedKeyLower = normalizedKey.toLowerCase()
|
||||
const normalizedFieldName = sanitizeFieldName(fieldName)
|
||||
const normalizedFieldNameUpper = normalizedFieldName.toUpperCase()
|
||||
|
||||
const visibleIdentifierKeys = new Set(['iccid', 'msisdn', 'imei', 'imsi', 'sn'])
|
||||
const visibleIdentifierNames = ['ICCID', 'MSISDN', 'IMEI', 'IMSI', 'SN']
|
||||
|
||||
if (
|
||||
visibleIdentifierKeys.has(normalizedKeyLower) ||
|
||||
visibleIdentifierNames.some((name) => normalizedFieldNameUpper.endsWith(name))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedKeyLower === 'id' ||
|
||||
normalizedFieldName === '绑定记录ID' ||
|
||||
normalizedFieldName === 'ID'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
normalizedKeyLower.endsWith('_id') ||
|
||||
/^[a-z][a-z0-9]*Id$/.test(normalizedKey) ||
|
||||
normalizedFieldName.endsWith('ID')
|
||||
)
|
||||
}
|
||||
|
||||
const extractOperationContent = (
|
||||
row: OperationLogRow,
|
||||
type: 'before' | 'after'
|
||||
): Record<string, any> => {
|
||||
const directContent =
|
||||
type === 'before' ? row.operation_content_before : row.operation_content_after
|
||||
if (directContent && Object.keys(directContent).length > 0) {
|
||||
return directContent
|
||||
}
|
||||
|
||||
const fallbackContainer = type === 'before' ? row.before_data : row.after_data
|
||||
const nestedContent = fallbackContainer?.operation_content
|
||||
if (nestedContent && Object.keys(nestedContent).length > 0) {
|
||||
return nestedContent
|
||||
}
|
||||
|
||||
return fallbackContainer || {}
|
||||
}
|
||||
|
||||
const isSameValue = (beforeValue: any, afterValue: any) => {
|
||||
if (beforeValue === afterValue) return true
|
||||
return JSON.stringify(beforeValue) === JSON.stringify(afterValue)
|
||||
}
|
||||
|
||||
const getChangeContent = (row: OperationLogRow): ChangeItem[] => {
|
||||
const before = extractOperationContent(row, 'before')
|
||||
const after = extractOperationContent(row, 'after')
|
||||
const desc = row.operation_fields_desc || {}
|
||||
const keys = new Set([...Object.keys(before), ...Object.keys(after)])
|
||||
|
||||
const changes: ChangeItem[] = []
|
||||
for (const key of keys) {
|
||||
const fieldName = sanitizeFieldName(desc[key] || fieldNameMap[key] || key)
|
||||
if (shouldHideIdField(key, fieldName)) continue
|
||||
|
||||
const beforeValue = before[key]
|
||||
const afterValue = after[key]
|
||||
if (isSameValue(beforeValue, afterValue)) continue
|
||||
|
||||
changes.push({
|
||||
type: 'change',
|
||||
key,
|
||||
fieldName,
|
||||
beforeValue,
|
||||
afterValue
|
||||
})
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
const getMessageItems = (row: OperationLogRow): MessageItem[] => {
|
||||
const items: MessageItem[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const pushMessage = (
|
||||
key: string,
|
||||
fieldName: string,
|
||||
message?: string,
|
||||
level: 'info' | 'error' = 'info'
|
||||
) => {
|
||||
const normalizedMessage = message?.trim()
|
||||
if (!normalizedMessage) return
|
||||
|
||||
const duplicateKey = `${fieldName}:${normalizedMessage}`
|
||||
if (seen.has(duplicateKey)) return
|
||||
seen.add(duplicateKey)
|
||||
|
||||
items.push({
|
||||
type: 'message',
|
||||
key,
|
||||
fieldName,
|
||||
message: normalizedMessage,
|
||||
level
|
||||
})
|
||||
}
|
||||
|
||||
pushMessage('error_msg', '失败原因', row.error_msg, 'error')
|
||||
pushMessage('reason', '原因说明', row.reason, row.result_status === 'failed' ? 'error' : 'info')
|
||||
|
||||
if (row.fail_count > 0) {
|
||||
pushMessage('fail_count', '批量结果', `失败 ${row.fail_count} 项`, 'error')
|
||||
}
|
||||
|
||||
row.failed_items?.slice(0, 3).forEach((item, index) => {
|
||||
const detail = item?.iccid
|
||||
? `${item.iccid}:${item.reason || '未知原因'}`
|
||||
: item?.reason || ''
|
||||
pushMessage(`failed_items_${index}`, '失败明细', detail, 'error')
|
||||
})
|
||||
|
||||
if (!items.length && row.result_status === 'failed') {
|
||||
pushMessage('result_status', '执行结果', '执行失败', 'error')
|
||||
}
|
||||
|
||||
if (!items.length && row.result_status === 'denied') {
|
||||
pushMessage('result_status', '执行结果', '操作被拒绝', 'info')
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
const getDisplayContent = (row: OperationLogRow): DisplayItem[] => {
|
||||
return [...getChangeContent(row), ...getMessageItems(row)]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.assetIdentifier,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
loadLogs()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.operation-logs-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
|
||||
.operation-type-select {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.result-status-select {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-table-wrapper {
|
||||
overflow-x: auto;
|
||||
|
||||
.logs-table {
|
||||
min-width: 980px;
|
||||
|
||||
:deep(.el-table__header-wrapper) {
|
||||
th {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
.change-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.change-item {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
|
||||
.field-name {
|
||||
min-width: 72px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.field-value {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
word-break: break-all;
|
||||
|
||||
.new-value {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.message-value {
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
&.is-error {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-pagination {
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.filter-section {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.filter-section {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
:deep(.el-select),
|
||||
.el-button,
|
||||
.el-tag {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-table-wrapper {
|
||||
padding: 0 12px;
|
||||
margin-right: -12px;
|
||||
margin-left: -12px;
|
||||
|
||||
:deep(.el-pagination) {
|
||||
justify-content: flex-start !important;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-loading="loading" class="package-table-wrapper">
|
||||
<div v-loading="props.loading" class="package-table-wrapper">
|
||||
<!-- 绑定设备提示 -->
|
||||
<ElEmpty v-if="props.boundDeviceId" :image-size="100">
|
||||
<template #description>
|
||||
@@ -32,15 +32,20 @@
|
||||
<ElTable :data="packageList" class="package-table" border>
|
||||
<ElTableColumn label="订单号" width="240">
|
||||
<template #default="scope">
|
||||
<ElButton
|
||||
type="primary"
|
||||
link
|
||||
@click="handleOrderNoClick(scope.row)"
|
||||
v-if="hasAuth('order:package_list_detail')"
|
||||
>
|
||||
{{ scope.row.order_no }}
|
||||
</ElButton>
|
||||
<span v-else>{{ scope.row.order_no }}</span>
|
||||
<div class="order-no-cell">
|
||||
<ElTooltip :content="scope.row.order_no" placement="top" :show-after="300">
|
||||
<ElButton
|
||||
class="order-no-link"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleOrderNoClick(scope.row)"
|
||||
v-if="hasAuth('order:package_list_detail')"
|
||||
>
|
||||
{{ scope.row.order_no }}
|
||||
</ElButton>
|
||||
<span v-else class="order-no-text">{{ scope.row.order_no }}</span>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="套餐名称" width="200" show-overflow-tooltip>
|
||||
@@ -69,7 +74,7 @@
|
||||
<template #default="scope">
|
||||
<div class="traffic-progress">
|
||||
<template v-if="isAdminOrPlatform && shouldShowAdminTrafficBreakdown(scope.row)">
|
||||
<div v-if="canViewCurrentPackageRealUsage" class="progress-text">
|
||||
<div v-if="canShowPackageRealUsage(scope.row)" class="progress-text">
|
||||
<span class="used"
|
||||
>已使用真流量: {{ formatDataSize(scope.row.real_used_mb || 0) }}</span
|
||||
>
|
||||
@@ -84,29 +89,60 @@
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
<span v-if="canShowPackageVirtualUsage(scope.row)" class="total"
|
||||
>停机阈值: {{ formatDataSize(scope.row.virtual_total_mb || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="canShowPackageRealUsage(scope.row)" class="progress-wrapper">
|
||||
<div class="progress-track">
|
||||
<ElProgress
|
||||
style="margin-top: 4px"
|
||||
:percentage="
|
||||
getUsageProgress(
|
||||
scope.row.real_used_mb || 0,
|
||||
scope.row.real_total_mb || 0
|
||||
)
|
||||
"
|
||||
:color="
|
||||
getProgressColorByPercentage(
|
||||
getUsageProgress(
|
||||
scope.row.real_used_mb || 0,
|
||||
scope.row.real_total_mb || 0
|
||||
)
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="canShowPackageVirtualUsage(scope.row)"
|
||||
class="breakpoint-marker"
|
||||
:style="
|
||||
getBreakpointMarkerStyle(
|
||||
scope.row.virtual_total_mb || 0,
|
||||
scope.row.real_total_mb || 0
|
||||
)
|
||||
"
|
||||
:title="`停机阈值/真流量总量: ${formatDataSize(scope.row.virtual_total_mb || 0)} / ${formatDataSize(scope.row.real_total_mb || 0)}`"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<ElProgress
|
||||
v-if="canViewCurrentPackageRealUsage"
|
||||
style="margin-top: 4px"
|
||||
:percentage="
|
||||
getUsageProgress(scope.row.real_used_mb || 0, scope.row.real_total_mb || 0)
|
||||
"
|
||||
:color="
|
||||
getProgressColorByPercentage(
|
||||
getUsageProgress(scope.row.real_used_mb || 0, scope.row.real_total_mb || 0)
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="canShowPackageVirtualUsage(scope.row)"
|
||||
class="progress-text"
|
||||
:style="{ marginTop: canViewCurrentPackageRealUsage ? '8px' : '0' }"
|
||||
:style="{ marginTop: canShowPackageRealUsage(scope.row) ? '8px' : '0' }"
|
||||
>
|
||||
<span class="used"
|
||||
>已使用虚流量: {{ formatDataSize(scope.row.virtual_used_mb || 0) }}</span
|
||||
>已使用流量: {{ formatDataSize(scope.row.virtual_used_mb || 0) }}</span
|
||||
>
|
||||
<span class="total"
|
||||
>停机阈值: {{ formatDataSize(scope.row.virtual_total_mb || 0) }}</span
|
||||
>总量: {{ formatDataSize(scope.row.real_total_mb || 0) }}</span
|
||||
>
|
||||
<span class="remaining"
|
||||
>剩余:
|
||||
{{
|
||||
formatDataSize(
|
||||
(scope.row.real_total_mb || 0) - (scope.row.virtual_used_mb || 0)
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="canShowPackageVirtualUsage(scope.row)" class="progress-wrapper">
|
||||
@@ -129,16 +165,6 @@
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="breakpoint-marker"
|
||||
:style="
|
||||
getBreakpointMarkerStyle(
|
||||
scope.row.virtual_total_mb || 0,
|
||||
scope.row.real_total_mb || 0
|
||||
)
|
||||
"
|
||||
:title="`停机阈值/真流量总量: ${formatDataSize(scope.row.virtual_total_mb || 0)} / ${formatDataSize(scope.row.real_total_mb || 0)}`"
|
||||
></div>
|
||||
</div>
|
||||
<span class="progress-percentage">
|
||||
{{
|
||||
@@ -293,8 +319,9 @@
|
||||
v-model="usedDataForm.data_usage"
|
||||
:min="0"
|
||||
:max="usedDataMaxValue"
|
||||
:precision="3"
|
||||
:step="0.1"
|
||||
:precision="0"
|
||||
:step="1"
|
||||
step-strictly
|
||||
class="used-data-input__number"
|
||||
placeholder="请输入新的套餐真实已用量"
|
||||
/>
|
||||
@@ -316,7 +343,7 @@
|
||||
<ElDialog
|
||||
v-model="expiresAtDialogVisible"
|
||||
title="修改过期时间"
|
||||
width="420px"
|
||||
width="40%"
|
||||
@closed="resetExpiresAtForm"
|
||||
>
|
||||
<ElForm
|
||||
@@ -369,7 +396,8 @@
|
||||
ElInputNumber,
|
||||
ElDatePicker,
|
||||
ElSelect,
|
||||
ElOption
|
||||
ElOption,
|
||||
ElTooltip
|
||||
} from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
@@ -430,7 +458,7 @@
|
||||
|
||||
const usedDataMaxValue = computed(() => {
|
||||
const realTotalMb = selectedPackage.value?.real_total_mb ?? 0
|
||||
return usedDataForm.unit === 'GB' ? Number((realTotalMb / 1024).toFixed(3)) : realTotalMb
|
||||
return usedDataForm.unit === 'GB' ? Math.floor(realTotalMb / 1024) : Math.floor(realTotalMb)
|
||||
})
|
||||
|
||||
const validateUsedData = (
|
||||
@@ -444,6 +472,11 @@
|
||||
return
|
||||
}
|
||||
|
||||
if (!Number.isInteger(usedDataForm.data_usage)) {
|
||||
callback(new Error('新的已用量必须为整数'))
|
||||
return
|
||||
}
|
||||
|
||||
if (dataUsageMb === undefined || dataUsageMb < 0) {
|
||||
callback(new Error('请输入可换算为非负数字 MB 的已用量'))
|
||||
return
|
||||
@@ -573,12 +606,15 @@
|
||||
// Props 使用 API 类型
|
||||
type PackageInfo = AssetPackageUsageRecord
|
||||
|
||||
const canShowPackageRealUsage = (pkg: PackageInfo) =>
|
||||
canViewCurrentPackageRealUsage.value || pkg.enable_virtual_data === false
|
||||
|
||||
const canShowPackageVirtualUsage = (pkg: PackageInfo) =>
|
||||
canViewCurrentPackageVirtualUsage.value && pkg.enable_virtual_data !== false
|
||||
|
||||
const shouldShowAdminTrafficBreakdown = (pkg: PackageInfo) => {
|
||||
if (!isAdminOrPlatform.value) return true
|
||||
return canViewCurrentPackageRealUsage.value || canShowPackageVirtualUsage(pkg)
|
||||
return canShowPackageRealUsage(pkg) || canShowPackageVirtualUsage(pkg)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -729,6 +765,35 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-no-cell {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.el-tooltip__trigger) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.order-no-link,
|
||||
.order-no-text {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-no-link {
|
||||
padding: 0;
|
||||
margin-left: 0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.package-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@@ -12,21 +12,21 @@
|
||||
<!-- 钱包余额展示 -->
|
||||
<div class="wallet-balance-cards">
|
||||
<div class="balance-card total">
|
||||
<div class="balance-icon">💰</div>
|
||||
<div class="balance-icon"><Money /></div>
|
||||
<div class="balance-info">
|
||||
<div class="balance-label">总余额</div>
|
||||
<div class="balance-value">{{ formatAmount(walletInfo.balance) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="balance-card available">
|
||||
<div class="balance-icon">✅</div>
|
||||
<div class="balance-icon"><CircleCheck /></div>
|
||||
<div class="balance-info">
|
||||
<div class="balance-label">可用余额</div>
|
||||
<div class="balance-value">{{ formatAmount(walletInfo.available_balance) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="balance-card frozen">
|
||||
<div class="balance-icon">🔒</div>
|
||||
<div class="balance-icon"><Lock /></div>
|
||||
<div class="balance-info">
|
||||
<div class="balance-label">冻结余额</div>
|
||||
<div class="balance-value">{{ formatAmount(walletInfo.frozen_balance) }}</div>
|
||||
@@ -40,6 +40,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElCard, ElTag, ElEmpty } from 'element-plus'
|
||||
import { CircleCheck, Lock, Money } from '@element-plus/icons-vue'
|
||||
import { useAssetFormatters } from '../composables/useAssetFormatters'
|
||||
|
||||
// Use formatters
|
||||
@@ -91,6 +92,11 @@
|
||||
flex-shrink: 0;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
|
||||
:deep(svg) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.balance-info {
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
<template>
|
||||
<ElCard shadow="never" class="info-card wallet-info" v-loading="walletLoading">
|
||||
<ElCard shadow="never" class="info-card wallet-info" v-loading="props.walletLoading">
|
||||
<template #header>
|
||||
<div class="card-header wallet-header">
|
||||
<div class="header-left">
|
||||
<div class="wallet-header-top">
|
||||
<span class="wallet-title">钱包</span>
|
||||
<ElTag v-if="walletInfo" :type="getWalletStatusType(walletInfo.status)" size="small">
|
||||
<ElTag
|
||||
v-if="walletInfo"
|
||||
class="wallet-status-tag"
|
||||
:type="getWalletStatusType(walletInfo.status)"
|
||||
size="large"
|
||||
>
|
||||
{{ walletInfo.status_text || '-' }}
|
||||
</ElTag>
|
||||
<template v-if="walletInfo">
|
||||
<ElDivider class="wallet-divider" direction="vertical" />
|
||||
<span class="balance-item"
|
||||
>💰 总余额 <strong>{{ formatAmount(walletInfo.balance) }}</strong></span
|
||||
>
|
||||
<ElDivider class="wallet-divider" direction="vertical" />
|
||||
<span class="balance-item"
|
||||
>✅ 可用 <strong>{{ formatAmount(walletInfo.available_balance) }}</strong></span
|
||||
>
|
||||
<ElDivider class="wallet-divider" direction="vertical" />
|
||||
<span class="balance-item"
|
||||
>🔒 冻结 <strong>{{ formatAmount(walletInfo.frozen_balance) }}</strong></span
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="walletInfo" class="wallet-balance-summary">
|
||||
<div class="balance-item">
|
||||
<span class="balance-label">总余额</span>
|
||||
<strong>{{ formatAmount(walletInfo.balance) }}</strong>
|
||||
</div>
|
||||
<div class="balance-item">
|
||||
<span class="balance-label">可用</span>
|
||||
<strong>{{ formatAmount(walletInfo.available_balance) }}</strong>
|
||||
</div>
|
||||
<div class="balance-item">
|
||||
<span class="balance-label">冻结</span>
|
||||
<strong>{{ formatAmount(walletInfo.frozen_balance) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!props.boundDeviceId" class="filter-section">
|
||||
<ElSelect
|
||||
@@ -45,15 +50,20 @@
|
||||
<ElButton type="primary" @click="handleQuery">查询</ElButton>
|
||||
<ElButton @click="handleReset()">重置</ElButton>
|
||||
<ElButton
|
||||
v-if="walletInfo?.wallet_id && hasAuth(AUDIT_PERMISSIONS.financeTimeline)"
|
||||
v-if="
|
||||
isPlatformAuditUser &&
|
||||
walletInfo?.wallet_id &&
|
||||
hasAuth(AUDIT_PERMISSIONS.assetInfoWalletFinance)
|
||||
"
|
||||
@click="openWalletAudit"
|
||||
>资金链路</ElButton
|
||||
>
|
||||
<ElButton
|
||||
v-if="
|
||||
isPlatformAuditUser &&
|
||||
walletInfo?.resource_type &&
|
||||
walletInfo?.resource_id &&
|
||||
hasAuth(AUDIT_PERMISSIONS.resourceTimeline)
|
||||
hasAuth(AUDIT_PERMISSIONS.assetInfoWalletAsset)
|
||||
"
|
||||
@click="openAssetAudit"
|
||||
>资产审计</ElButton
|
||||
@@ -79,7 +89,7 @@
|
||||
|
||||
<div v-else v-loading="loading" class="wallet-table-wrapper">
|
||||
<ElTable v-if="hasTransactions" :data="transactionList" class="wallet-table" border>
|
||||
<ElTableColumn prop="reference_no" label="业务编号">
|
||||
<ElTableColumn prop="reference_no" label="业务编号" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.reference_no" class="reference-no-link">
|
||||
{{ scope.row.reference_no }}
|
||||
@@ -87,14 +97,14 @@
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="交易类型" width="100" align="center">
|
||||
<ElTableColumn label="交易类型" width="100">
|
||||
<template #default="scope">
|
||||
<ElTag :type="getTransactionTypeTag(scope.row.transaction_type)" size="small">
|
||||
{{ scope.row.transaction_type_text }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="变动金额" width="120" align="right">
|
||||
<ElTableColumn label="变动金额" width="120">
|
||||
<template #default="scope">
|
||||
<span
|
||||
:style="{
|
||||
@@ -106,17 +116,17 @@
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="变动前余额" width="120" align="right">
|
||||
<ElTableColumn label="变动前余额" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatAmount(scope.row.balance_before) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="变动后余额" width="120" align="right">
|
||||
<ElTableColumn label="变动后余额" width="120">
|
||||
<template #default="scope">
|
||||
{{ formatAmount(scope.row.balance_after) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="关联业务" width="100" align="center">
|
||||
<ElTableColumn label="关联业务" width="120">
|
||||
<template #default="scope">
|
||||
<ElTag v-if="scope.row.reference_type" type="info" size="small">
|
||||
{{ scope.row.reference_type === 'recharge' ? '充值' : '订单' }}
|
||||
@@ -124,7 +134,7 @@
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="remark" label="备注" min-width="150" show-overflow-tooltip>
|
||||
<ElTableColumn prop="remark" label="备注" min-width="180" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
{{ scope.row.remark || '-' }}
|
||||
</template>
|
||||
@@ -168,8 +178,7 @@
|
||||
ElDatePicker,
|
||||
ElPagination,
|
||||
ElEmpty,
|
||||
ElMessage,
|
||||
ElDivider
|
||||
ElMessage
|
||||
} from 'element-plus'
|
||||
import { useAssetFormatters } from '../composables/useAssetFormatters'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
@@ -182,7 +191,9 @@
|
||||
TransactionType
|
||||
} from '@/types/api'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { isPlatformAuditAccount } from '@/utils/business/auditAccess'
|
||||
import {
|
||||
resolveAuditResourceTarget,
|
||||
resolveFinanceAuditTarget
|
||||
@@ -204,6 +215,10 @@
|
||||
boundDeviceNo: ''
|
||||
})
|
||||
const { hasAuth } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
const isPlatformAuditUser = computed(() =>
|
||||
isPlatformAuditAccount(userStore.info.user_type, userStore.isSuperAdmin)
|
||||
)
|
||||
|
||||
const openWalletAudit = () => {
|
||||
const target = resolveFinanceAuditTarget('wallet_id', props.walletInfo?.wallet_id)
|
||||
@@ -373,35 +388,61 @@
|
||||
&.wallet-info {
|
||||
.wallet-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
|
||||
.header-left {
|
||||
.wallet-header-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wallet-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wallet-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wallet-divider {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wallet-balance-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.balance-item {
|
||||
.balance-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
white-space: nowrap;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
|
||||
.balance-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.wallet-status-tag {
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
@@ -410,6 +451,7 @@
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
.transaction-type-select {
|
||||
@@ -433,9 +475,6 @@
|
||||
}
|
||||
|
||||
.wallet-table {
|
||||
width: max-content;
|
||||
min-width: 900px;
|
||||
|
||||
:deep(.el-table__header) {
|
||||
th {
|
||||
font-weight: 600;
|
||||
@@ -454,30 +493,14 @@
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.wallet-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.header-left,
|
||||
.filter-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
justify-content: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.wallet-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.header-left,
|
||||
.filter-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -494,18 +517,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
|
||||
.wallet-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.balance-item {
|
||||
width: 100%;
|
||||
}
|
||||
.wallet-balance-summary {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<ElDialog v-model="visible" title="套餐流量详单" width="900px" destroy-on-close>
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
title="套餐流量详单"
|
||||
width="900px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-if="data">
|
||||
<!-- 套餐信息摘要 -->
|
||||
<ElDescriptions :column="2" border style="margin-bottom: 20px">
|
||||
@@ -45,13 +50,13 @@
|
||||
|
||||
<!-- 每日流量记录表格 -->
|
||||
<ElTable :data="filteredRecords" border stripe max-height="400" v-loading="loading">
|
||||
<ElTableColumn prop="date" label="日期" width="120" align="center" />
|
||||
<ElTableColumn label="当日使用流量" width="150" align="center">
|
||||
<ElTableColumn prop="date" label="日期" width="120" />
|
||||
<ElTableColumn label="当日使用流量" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ formatDataSize(row.daily_usage_mb) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="累计流量" width="150" align="center">
|
||||
<ElTableColumn label="累计流量" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ formatDataSize(row.cumulative_usage_mb) }}
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<ElDialog v-model="visible" title="往期订单" width="60%" destroy-on-close>
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
title="往期订单"
|
||||
width="60%"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-if="loading" style="padding: 40px 0; text-align: center">
|
||||
<ElIcon class="is-loading" :size="40"><Loading /></ElIcon>
|
||||
</div>
|
||||
@@ -132,8 +137,7 @@
|
||||
ElPagination,
|
||||
ElAlert,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
ElMessage
|
||||
ElIcon
|
||||
} from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { AssetService } from '@/api/modules'
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<ElDialog v-model="visible" title="套餐充值" width="30%" @closed="handleDialogClosed">
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
title="套餐充值"
|
||||
width="40%"
|
||||
@closed="handleDialogClosed"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">
|
||||
<ElFormItem label="资产信息">
|
||||
<span style="font-weight: bold; color: #409eff">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user