Compare commits

...

24 Commits

Author SHA1 Message Date
luo
d8191f03a0 fix: 资产信息ui
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 24m28s
2026-08-21 16:56:34 +08:00
luo
4d023e7666 fix: update some files
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m35s
2026-08-19 18:14:20 +08:00
luo
fe357f56d9 fix: update some files
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 8m2s
2026-08-19 17:38:09 +08:00
luo
2cb961fd1b fix: update some files
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 6m20s
2026-08-19 15:53:55 +08:00
luo
4e3ea6c6d6 fix: update some files
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 7m7s
2026-08-19 14:18:55 +08:00
luo
2d6948010b fix: update some files
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 10m55s
2026-08-18 18:05:40 +08:00
luo
95abcb97ef fix: update some files
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Has been cancelled
2026-08-18 17:59:31 +08:00
luo
d9d07422a9 fix: update some files
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m44s
2026-08-18 17:36:41 +08:00
luo
93f67967c5 fix: formate code and test money
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m8s
2026-08-18 10:54:01 +08:00
luo
857d565f33 fix: 我的佣金
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m35s
2026-08-17 17:29:33 +08:00
luo
9a982e0446 fix: 我的佣金
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m0s
2026-08-15 11:03:53 +08:00
luo
737ade3a5e fix: router, 佣金
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 7m20s
2026-08-14 17:22:47 +08:00
luo
0d2a982f69 fix: ui
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 7m52s
2026-08-13 17:29:23 +08:00
luo
3dd17e9d7b fix: ui
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Has been cancelled
2026-08-13 17:27:58 +08:00
luo
ca748999ec fix: 提现限制
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m39s
2026-08-13 16:51:58 +08:00
luo
e86f61f3f6 fix: 提现限制
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m39s
2026-08-13 15:21:50 +08:00
luo
10ee87bf49 fix: 提现限制
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 6m32s
2026-08-13 14:43:42 +08:00
luo
32635da58c fix: 提现限制
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Has been cancelled
2026-08-13 14:39:22 +08:00
luo
8627d203cf fix: 提现限制
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 7m12s
2026-08-13 14:16:12 +08:00
luo
41f3b61b62 fix: 优化外部交互弹窗
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m15s
2026-08-12 09:34:36 +08:00
luo
33485b137f fix: 优化审计
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 3m42s
2026-08-11 16:47:58 +08:00
luo
9599a4d52b fix: 代理系列授权和操作日志去掉
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m58s
2026-08-10 17:27:15 +08:00
luo
8650b490d7 fix: 扫码
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 11m24s
2026-08-10 15:57:09 +08:00
luo
373ad4c2a5 fix: 审计事件详情 2026-08-10 14:44:59 +08:00
139 changed files with 4677 additions and 2738 deletions

View 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
- 后端对可补发记录的最终状态校验以及幂等性需确认。
- 两个接口是否都需要独立权限码,还是复用现有审批/财务权限,需与后端权限配置对齐。

View 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:
- 企微审批的发起、撤回、通过、驳回或删除动作本身。
- 修改历史记录的业务字段、金额或支付/退款结果。
- 自动判断并批量补发历史审批。

View File

@@ -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 将记录标记为已补发审批

View File

@@ -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 将记录标记为已补发审批

View 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 手工验证有/无审批实例、线上/线下充值、权限开关等场景

View File

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

View File

@@ -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** 页面不展示或调用任何写入、导出、恢复、重试、补偿或风险处置操作

View File

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

View File

@@ -0,0 +1,16 @@
# Change: 补全操作者行为时间线事件信息
## Why
操作者行为时间线接口已返回完整审计事件字段,但当前调查抽屉仅展示动作、结果、摘要、操作者名称和来源,无法支持对操作者所属组织、业务范围、请求上下文、批次结果、失败原因和关联资源的有效追溯。
## What Changes
- 在操作者行为时间线的每个事件节点展示接口返回的分类、风险、操作者身份与组织快照、业务范围、请求摘要、批次统计和错误摘要。
- 展示关联资源的名称、类型、关系、业务角色和主体摘要;将资源快照与前后变更数据放入按需展开的只读区域。
- 对缺失或空字段使用明确的空态,不从名称、时间或相邻事件推断数据;保留分页、排序与只读边界。
## Impact
- Affected specs: `audit-chain-frontend-integration`
- Affected code: `src/components/business/audit/AuditInvestigationDrawer.vue`

View File

@@ -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** 不提供修改、删除、导出、恢复、重试、补偿或风险处置操作

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

View File

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

View File

@@ -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** 前端不提供修改、删除、导出、恢复、重试、补偿或风险处置操作

View 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 严格校验。

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

View File

@@ -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** 保持分页和排序结果不变

View 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 严格校验。

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

View File

@@ -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** 前端不提供修改、删除、导出、恢复、重试、补偿或风险处置操作

View 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 严格校验。

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

View File

@@ -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** 前端继续使用接口返回的页码、每页数量和稳定排序结果

View 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 严格校验。

View File

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

View File

@@ -0,0 +1,11 @@
## REMOVED Requirements
### Requirement: 旧资产操作审计日志入口
**Reason**: 旧资产操作审计日志交互已由当前审计调查能力替代,页面不再提供重复入口。 **Migration**: 需要审计调查的用户通过当前审计中心和资源审计时间线访问记录。
#### Scenario: 查看 IoT 卡、设备或资产信息
- **WHEN** 用户打开 IoT 卡管理、设备管理或资产信息页面
- **THEN** 页面不显示旧的“操作审计日志”入口、弹窗或内嵌日志卡片
- **AND** 前端不因这些页面加载或操作而请求旧资产操作日志接口

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

View File

@@ -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: 详情页面本身也包含下载和取消按钮,若继续使用通用权限会绕过三个列表页面的按钮隔离要求。

View File

@@ -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` 表示;本次不扩展后端分类分页契约。

View File

@@ -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:
- 不改变通知接口、通知分类统计和已读接口。
- 不接入下滑加载、无限滚动或新的实时推送机制。

View File

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

View File

@@ -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、类型、格式、样式和编码检查当前仓库暂无通知专项测试。

View File

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

View File

@@ -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** 仅使用新组合返回的候选项进行后续选择

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,11 @@
<ElDialog
v-model="dialogVisible"
title="手动更新卡实名状态"
width="400px"
width="30%"
modal-class="asset-information-dialog"
@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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">&#xe6c2;</i>
<span v-if="unreadCount > 0" class="notice-count">{{ formattedUnreadCount }}</span>

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
<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%"
modal-class="asset-information-dialog"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">
<ElFormItem label="资产标识">
<span style="font-weight: bold; color: #409eff">{{ assetIdentifier }}</span>
</ElFormItem>
@@ -9,7 +14,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>

View File

@@ -1,5 +1,11 @@
<template>
<ElDialog v-model="visible" title="切换SIM卡" width="600px" @close="handleClose">
<ElDialog
v-model="visible"
title="切换SIM卡"
width="40%"
modal-class="asset-information-dialog"
@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 +30,7 @@
ref="formRef"
:model="formData"
:rules="rules"
label-width="120px"
label-width="90px"
>
<ElFormItem label="设备信息">
<span style="font-weight: bold; color: #409eff">{{ deviceInfo }}</span>

View File

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

View File

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

View File

@@ -408,13 +408,13 @@
"agentRecharge": "代理充值",
"agentRechargeDetail": "代理充值详情",
"refundManagement": "退款管理",
"refundDetail": "退款详情"
"refundDetail": "退款详情",
"agentFundOverview": "代理商资金概况"
},
"commission": {
"title": "佣金管理",
"withdrawal": "提现审批",
"myCommission": "我的佣金",
"agentCommission": "代理商资金概况"
"myCommission": "我的佣金"
},
"settings": {
"title": "设置管理",

View File

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

View File

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

View File

@@ -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', // 代理商资金概况组件路径
// 设置管理

View File

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

View File

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

View File

@@ -594,51 +594,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' // 执行结果
}

View File

@@ -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 // 用户名
}

View File

@@ -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
}
/**
* 创建代理系列授权请求
*/

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">
@@ -186,35 +181,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 +255,12 @@
<!-- 空状态 -->
<ElEmpty v-else-if="!loading" :description="errorMsg || '暂无当前生效套餐'" :image-size="100" />
</ElCard>
</div>
</template>
<script setup lang="ts">
import {
ElCard,
ElDivider,
ElButton,
ElDescriptions,
ElDescriptionsItem,
@@ -296,7 +329,7 @@
const descriptionsColumn = computed(() => {
if (width.value <= 640) return 1
if (width.value <= 1024) return 2
return 4
return 5
})
const emit = defineEmits<{
@@ -322,16 +355,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 +580,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 +590,6 @@
}
@media (width <= 768px) {
.card-header {
flex-direction: column;
align-items: flex-start;
.header-actions {
width: 100%;
}
}
.flow-progress-card {
padding: 14px;

View File

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

View File

@@ -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>
@@ -316,7 +321,7 @@
<ElDialog
v-model="expiresAtDialogVisible"
title="修改过期时间"
width="420px"
width="40%"
@closed="resetExpiresAtForm"
>
<ElForm
@@ -369,7 +374,8 @@
ElInputNumber,
ElDatePicker,
ElSelect,
ElOption
ElOption,
ElTooltip
} from 'element-plus'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
@@ -729,6 +735,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);

View File

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

View File

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

View File

@@ -1,5 +1,11 @@
<template>
<ElDialog v-model="visible" title="套餐流量详单" width="900px" destroy-on-close>
<ElDialog
v-model="visible"
title="套餐流量详单"
width="900px"
modal-class="asset-information-dialog"
destroy-on-close
>
<div v-if="data">
<!-- 套餐信息摘要 -->
<ElDescriptions :column="2" border style="margin-bottom: 20px">
@@ -45,13 +51,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>

View File

@@ -1,5 +1,11 @@
<template>
<ElDialog v-model="visible" title="往期订单" width="60%" destroy-on-close>
<ElDialog
v-model="visible"
title="往期订单"
width="60%"
modal-class="asset-information-dialog"
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 +138,7 @@
ElPagination,
ElAlert,
ElEmpty,
ElIcon,
ElMessage
ElIcon
} from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { AssetService } from '@/api/modules'

View File

@@ -1,5 +1,11 @@
<template>
<ElDialog v-model="visible" title="套餐充值" width="30%" @closed="handleDialogClosed">
<ElDialog
v-model="visible"
title="套餐充值"
width="40%"
modal-class="asset-information-dialog"
@closed="handleDialogClosed"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">
<ElFormItem label="资产信息">
<span style="font-weight: bold; color: #409eff">

View File

@@ -5,7 +5,7 @@
width="500px"
:lock-scroll="true"
:z-index="4000"
modal-class="speed-limit-modal"
modal-class="asset-information-dialog speed-limit-modal"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="固定档位" prop="code">

View File

@@ -1,6 +1,11 @@
<template>
<ElDialog v-model="visible" title="设置切卡模式" width="500px">
<ElForm ref="formRef" :model="form" :rules="rules" label-width="96px">
<ElDialog
v-model="visible"
title="设置切卡模式"
width="45%"
modal-class="asset-information-dialog"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px">
<ElFormItem label="设备标识">
<span class="device-identifier">{{ deviceIdentifier || '-' }}</span>
</ElFormItem>

View File

@@ -1,5 +1,11 @@
<template>
<ElDialog v-model="visible" title="设置WiFi" width="30%" :append-to-body="true">
<ElDialog
v-model="visible"
title="设置WiFi"
width="40%"
modal-class="asset-information-dialog"
:append-to-body="true"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px" autocomplete="off">
<ElFormItem label="WiFi状态" prop="enabled">
<ElRadioGroup v-model="form.enabled">

View File

@@ -1,5 +1,5 @@
<template>
<div class="single-card-page">
<div class="single-card-page asset-information-page">
<!-- 资产查询区域 -->
<AssetSearchCard
ref="assetSearchCardRef"
@@ -12,54 +12,55 @@
<!-- 卡片内容区域 -->
<div v-if="cardInfo" class="card-content-area slide-in">
<div class="main-content-layout">
<!-- 第一行基本信息 -->
<div class="row full-width">
<BasicInfoCard
:card-info="cardInfo"
:device-realtime="deviceRealtime"
v-model:polling-enabled="pollingEnabled"
:realtime-loading="realtimeLoading"
:start-disabled="startDisabled"
:start-error-message="startFailureMessage"
:stop-disabled="stopDisabled"
:start-remaining-text="startRemainingText"
:stop-remaining-text="stopRemainingText"
:get-start-remaining-text="getStartRemainingText"
:get-stop-remaining-text="getStopRemainingText"
:is-start-disabled="isStartActionDisabled"
:is-stop-disabled="isStopActionDisabled"
@enable-card="handleEnableCard"
@disable-card="handleDisableCard"
@manual-deactivate="handleManualDeactivate"
@reboot-device="handleRebootDevice"
@reset-device="handleResetDevice"
@show-switch-card="showSwitchCardDialog"
@show-switch-mode="showSwitchModeDialog"
@show-set-wifi="showSetWiFiDialog"
@show-realname-policy="showRealnamePolicyDialog"
@show-speed-limit="speedLimitDialogVisible = true"
@show-update-realname-status="showUpdateRealnameStatusDialog"
@enable-binding-card="handleEnableBindingCard"
@disable-binding-card="handleDisableBindingCard"
@update-binding-card-realname-status="handleUpdateBindingCardRealnameStatus"
@navigate-to-asset="handleNavigateToAsset"
@navigate-to-card="handleNavigateToCard"
@navigate-to-device="handleNavigateToDevice"
/>
</div>
<!-- 第二行当前生效套餐 -->
<div class="row full-width">
<CurrentPackageCard
:current-package="currentPackage"
:loading="currentPackageLoading"
:error-msg="currentPackageErrorMsg"
:bound-device-id="cardInfo.bound_device_id"
:bound-device-no="cardInfo.bound_device_no"
:bound-device-name="cardInfo.bound_device_name"
@show-recharge="showRechargeDialog"
@navigate-to-device="handleNavigateToDevice"
/>
<div class="overview-grid">
<!-- 第一层资产概览与当前生效套餐按信息高度纵向展开 -->
<div class="row overview-primary">
<BasicInfoCard
:card-info="cardInfo"
:device-realtime="deviceRealtime"
v-model:polling-enabled="pollingEnabled"
:realtime-loading="realtimeLoading"
:start-disabled="startDisabled"
:start-error-message="startFailureMessage"
:stop-disabled="stopDisabled"
:start-remaining-text="startRemainingText"
:stop-remaining-text="stopRemainingText"
:get-start-remaining-text="getStartRemainingText"
:get-stop-remaining-text="getStopRemainingText"
:is-start-disabled="isStartActionDisabled"
:is-stop-disabled="isStopActionDisabled"
@enable-card="handleEnableCard"
@disable-card="handleDisableCard"
@manual-deactivate="handleManualDeactivate"
@reboot-device="handleRebootDevice"
@reset-device="handleResetDevice"
@show-switch-card="showSwitchCardDialog"
@show-switch-mode="showSwitchModeDialog"
@show-set-wifi="showSetWiFiDialog"
@show-realname-policy="showRealnamePolicyDialog"
@show-speed-limit="speedLimitDialogVisible = true"
@show-update-realname-status="showUpdateRealnameStatusDialog"
@enable-binding-card="handleEnableBindingCard"
@disable-binding-card="handleDisableBindingCard"
@update-binding-card-realname-status="handleUpdateBindingCardRealnameStatus"
@navigate-to-asset="handleNavigateToAsset"
@navigate-to-card="handleNavigateToCard"
@navigate-to-device="handleNavigateToDevice"
>
<template #current-package>
<CurrentPackageCard
:current-package="currentPackage"
:loading="currentPackageLoading"
:error-msg="currentPackageErrorMsg"
:bound-device-id="cardInfo.bound_device_id"
:bound-device-no="cardInfo.bound_device_no"
:bound-device-name="cardInfo.bound_device_name"
@show-recharge="showRechargeDialog"
@navigate-to-device="handleNavigateToDevice"
/>
</template>
</BasicInfoCard>
</div>
</div>
<!-- 第三行套餐列表 -->
@@ -96,15 +97,6 @@
@navigate-to-device="handleNavigateToDevice"
/>
</div>
<!-- 第五行操作审计日志 -->
<div class="row full-width">
<OperationLogsCard
:asset-identifier="cardInfo.identifier"
:asset-type="cardInfo.asset_type"
download-permission="asset_info:download_log_file"
/>
</div>
</div>
</div>
@@ -204,7 +196,6 @@
import CurrentPackageCard from './components/CurrentPackageCard.vue'
import PackageListCard from './components/PackageListCard.vue'
import WalletTransactionCard from './components/WalletTransactionCard.vue'
import OperationLogsCard from './components/OperationLogsCard.vue'
// 引入对话框组件
import {
@@ -999,6 +990,12 @@
}
// 主内容布局
.overview-grid {
display: flex;
flex-direction: column;
gap: 24px;
}
.main-content-layout {
display: flex;
flex-direction: column;
@@ -1067,6 +1064,10 @@
}
@media (width <= 768px) {
.overview-grid {
gap: 16px;
}
gap: 16px;
.row {
@@ -1646,3 +1647,118 @@
}
}
</style>
<style lang="scss" scoped>
.asset-information-page {
background: #ffffff;
}
</style>
<style lang="scss">
/* Dialogs are teleported to body, so their surface needs a page-level hook. */
.asset-information-dialog.el-dialog {
max-width: calc(100vw - 32px);
overflow: hidden;
border: 1px solid #e5eaf1;
border-radius: 14px;
box-shadow: 0 18px 50px rgb(15 23 42 / 18%);
.el-dialog__header {
padding: 20px 24px 16px;
margin: 0;
border-bottom: 1px solid #e5eaf1;
}
.el-dialog__title {
font-size: 16px;
font-weight: 650;
color: #172033;
letter-spacing: -0.01em;
}
.el-dialog__body {
padding: 22px 24px 20px;
}
.el-dialog__footer {
padding: 14px 24px 20px;
border-top: 1px solid #eef2f7;
}
.el-form-item {
margin-bottom: 18px;
}
.el-form-item__label {
color: #526078;
}
.el-button {
min-height: 34px;
border-radius: 8px;
}
.el-table {
--el-table-header-bg-color: #f8fafc;
--el-table-row-hover-bg-color: #f5f8ff;
--el-table-border-color: #e5eaf1;
border-radius: 9px;
}
.el-descriptions__label.el-descriptions__cell.is-bordered-label {
color: #526078;
background: #f8fafc;
}
.dialog-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
@media (width <= 768px) {
.asset-information-dialog.el-dialog {
width: calc(100vw - 20px) !important;
.el-dialog__header {
padding: 16px 18px 13px;
}
.el-dialog__body {
max-height: calc(100vh - 150px);
padding: 18px;
overflow-y: auto;
}
.el-dialog__footer {
padding: 12px 18px 16px;
}
}
}
html.dark .asset-information-dialog.el-dialog {
border-color: #2b3a52;
.el-dialog__header,
.el-dialog__footer {
border-color: #2b3a52;
}
.el-dialog__title,
.el-form-item__label {
color: #e5edf8;
}
.el-table {
--el-table-header-bg-color: #202d42;
--el-table-row-hover-bg-color: #223451;
--el-table-border-color: #2b3a52;
}
.el-descriptions__label.el-descriptions__cell.is-bordered-label {
color: #afbdd0;
background: #202d42;
}
}
</style>

View File

@@ -93,17 +93,17 @@
</ArtTable>
<!-- 批量分配对话框 -->
<ElDialog v-model="allocateDialogVisible" title="批量分配设备" width="600px">
<ElDialog v-model="allocateDialogVisible" title="批量分配设备" width="40%">
<ElForm
ref="allocateFormRef"
:model="allocateForm"
:rules="allocateRules"
label-width="120px"
label-width="90px"
>
<ElFormItem label="已选设备数">
<span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="店铺" prop="target_shop_id">
<ElFormItem label="目标店铺" prop="target_shop_id">
<ElCascader
v-model="allocateForm.target_shop_id"
:options="shopCascadeOptions"
@@ -166,8 +166,8 @@
</ElDialog>
<!-- 批量回收对话框 -->
<ElDialog v-model="recallDialogVisible" title="批量回收设备" width="600px">
<ElForm ref="recallFormRef" :model="recallForm" label-width="120px">
<ElDialog v-model="recallDialogVisible" title="批量回收设备" width="40%">
<ElForm ref="recallFormRef" :model="recallForm" label-width="90px">
<ElFormItem label="已选设备数">
<span style="font-weight: bold; color: #e6a23c">{{ selectedDevices.length }}</span>
</ElFormItem>
@@ -225,14 +225,14 @@
<ElDialog
v-model="seriesBindingDialogVisible"
title="批量设置设备套餐系列绑定"
width="35%"
width="40%"
@close="handleCloseSeriesBindingDialog"
>
<ElForm
ref="seriesBindingFormRef"
:model="seriesBindingForm"
:rules="seriesBindingRules"
label-width="120px"
label-width="100px"
>
<ElFormItem label="已选设备数">
<span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span>
@@ -397,19 +397,19 @@
>
<ElDatePicker
v-model="seriesBindingForm.created_at_range"
type="daterange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD"
value-format="YYYY-MM-DDTHH:mm:ssZ"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="目标套餐系列" prop="series_id">
<ElFormItem label="套餐系列" prop="series_id">
<ElSelect
v-model="seriesBindingForm.series_id"
placeholder="请选择或搜索套餐系列"
placeholder="请选择或搜索目标套餐系列"
style="width: 100%"
filterable
remote
@@ -703,23 +703,17 @@
@confirm="handleConfirmBatchRealnamePolicy"
/>
<!-- 操作审计日志弹窗 -->
<OperationLogsDialog
v-model="operationLogsDialogVisible"
:identifier="operationLogsIdentifier"
download-permission="device:download_log_file"
/>
<ElDialog
v-model="enterpriseDeviceAuthorizeDialogVisible"
title="授权设备给企业"
width="620px"
width="40%"
@close="handleEnterpriseDeviceAuthorizeDialogClose"
>
<ElForm
ref="enterpriseDeviceAuthorizeFormRef"
:model="enterpriseDeviceAuthorizeForm"
:rules="enterpriseDeviceAuthorizeRules"
label-width="120px"
label-width="100px"
>
<ElFormItem label="授权企业" prop="enterprise_id">
<ElSelect
@@ -818,14 +812,14 @@
<ElDialog
v-model="enterpriseDeviceRecallDialogVisible"
title="撤销设备授权"
width="620px"
width="40%"
@close="handleEnterpriseDeviceRecallDialogClose"
>
<ElForm
ref="enterpriseDeviceRecallFormRef"
:model="enterpriseDeviceRecallForm"
:rules="enterpriseDeviceRecallRules"
label-width="120px"
label-width="100px"
>
<ElFormItem label="授权企业" prop="enterprise_id">
<ElSelect
@@ -1012,7 +1006,6 @@
} from '@/types/api/enterpriseDevice'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
import BatchRealnamePolicyDialog from '@/components/business/BatchRealnamePolicyDialog.vue'
import OperationLogsDialog from '@/components/business/OperationLogsDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import type { AssetRealnamePolicy } from '@/types/api'
@@ -1045,8 +1038,6 @@
const batchRealnamePolicyDialogVisible = ref(false)
const batchRealnamePolicyLoading = ref(false)
const batchRealnamePolicyError = ref('')
const operationLogsDialogVisible = ref(false)
const operationLogsIdentifier = ref('')
const exportDialogVisible = ref(false)
const shopCascadeOptions = ref<any[]>([])
const shopCascadeProps = {
@@ -1546,13 +1537,13 @@
{
label: '起止时间',
prop: 'dateRange',
type: 'date',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DD'
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
]
@@ -3167,23 +3158,12 @@
})
}
if (hasAuth('device:operation_logs')) {
moreActions.push({
label: '操作审计日志',
handler: () => {
operationLogsIdentifier.value = row.virtual_no
operationLogsDialogVisible.value = true
},
type: 'primary'
})
}
const userType = Number(userStore.getUserInfo.user_type)
const auditPermission =
userType === 3
? AUDIT_PERMISSIONS.agentActivity
? AUDIT_PERMISSIONS.agentDeviceActivity
: userType === 4
? AUDIT_PERMISSIONS.enterpriseActivity
? AUDIT_PERMISSIONS.enterpriseDeviceActivity
: AUDIT_PERMISSIONS.deviceEntry
if (hasAuth(auditPermission)) {
const auditTarget = resolveAuditResourceTarget({

View File

@@ -10,30 +10,6 @@
返回
</ElButton>
<h2 class="detail-title">换货单详情</h2>
<ElButton
v-if="exchangeAuditTarget && hasAuth(exchangeAuditPermission)"
type="primary"
plain
@click="openAuditInvestigation(exchangeAuditTarget)"
>
{{ userType === 3 ? '活动记录' : '换货审计' }}
</ElButton>
<ElButton
v-if="oldAssetAuditTarget && hasAuth(AUDIT_PERMISSIONS.resourceTimeline)"
type="primary"
plain
@click="openAuditInvestigation(oldAssetAuditTarget)"
>
旧资产审计
</ElButton>
<ElButton
v-if="newAssetAuditTarget && hasAuth(AUDIT_PERMISSIONS.resourceTimeline)"
type="primary"
plain
@click="openAuditInvestigation(newAssetAuditTarget)"
>
新资产审计
</ElButton>
</div>
<!-- 详情内容 -->
@@ -59,51 +35,15 @@
import { formatDateTime } from '@/utils/business/format'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
defineOptions({ name: 'ExchangeDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const userStore = useUserStore()
const loading = ref(false)
const exchangeDetail = ref<ExchangeResponse | null>(null)
const exchangeId = ref<number>(0)
const userType = computed(() => Number(userStore.info.user_type))
const exchangeAuditPermission = computed(() =>
userType.value === 3 ? AUDIT_PERMISSIONS.agentActivity : AUDIT_PERMISSIONS.resourceTimeline
)
const exchangeAuditTarget = computed(() => {
if (!exchangeDetail.value || userType.value === 4) return null
return resolveAuditResourceTarget({
userType: userType.value,
resourceType: 'exchange_order',
internalId: exchangeDetail.value.id,
businessIdentifier: exchangeDetail.value.exchange_no
})
})
const oldAssetAuditTarget = computed(() => {
if (!exchangeDetail.value || ![1, 2].includes(userType.value)) return null
return resolveAuditResourceTarget({
userType: userType.value,
resourceType: exchangeDetail.value.old_asset_type,
internalId: exchangeDetail.value.old_asset_id
})
})
const newAssetAuditTarget = computed(() => {
if (!exchangeDetail.value || ![1, 2].includes(userType.value)) return null
return resolveAuditResourceTarget({
userType: userType.value,
resourceType: exchangeDetail.value.new_asset_type || '',
internalId: exchangeDetail.value.new_asset_id
})
})
const formatExchangeShopName = (shopName?: string | null, shopId?: number | null) => {
if (shopName) return shopName

View File

@@ -56,11 +56,11 @@
<ElDialog
v-model="createDialogVisible"
title="创建换货单"
width="600px"
width="40%"
:close-on-click-modal="false"
@closed="handleCloseCreateDialog"
>
<ElForm ref="createFormRef" :model="createForm" :rules="createRules" label-width="120px">
<ElForm ref="createFormRef" :model="createForm" :rules="createRules" label-width="110px">
<ElFormItem label="流程类型" prop="flow_type">
<ElSelect
v-model="createForm.flow_type"
@@ -121,7 +121,7 @@
</ElFormItem>
<ElFormItem
v-if="createForm.old_asset_type === 'iot_card'"
label="旧资产标识"
label="旧资产标识"
prop="old_identifier"
>
<ElSelect
@@ -193,7 +193,7 @@
{{ oldDeviceEmptyMessage }}
</div>
</ElFormItem>
<ElFormItem v-if="createOldAssetShopDisplay" label="旧资产所属店铺">
<ElFormItem v-if="createOldAssetShopDisplay" label="旧资产店铺">
<ElInput :model-value="createOldAssetShopDisplay" readonly disabled />
</ElFormItem>
<ElFormItem
@@ -303,14 +303,14 @@
<ElDialog
v-model="shipDialogVisible"
title="换货发货"
width="600px"
width="40%"
:close-on-click-modal="false"
@closed="handleCloseShipDialog"
>
<ElForm ref="shipFormRef" :model="shipForm" :rules="shipRules" label-width="120px">
<ElForm ref="shipFormRef" :model="shipForm" :rules="shipRules" label-width="100px">
<ElFormItem
v-if="shipAssetType === 'iot_card'"
label="新资产标识"
label="新资产标识"
prop="new_identifier"
>
<ElSelect
@@ -347,7 +347,7 @@
</ElFormItem>
<ElFormItem
v-else-if="shipAssetType === 'device'"
label="新资产标识"
label="新资产标识"
prop="new_identifier"
>
<ElSelect
@@ -851,15 +851,15 @@
}
},
{
label: '创建时间',
label: '起止时间',
prop: 'created_at_range',
type: 'date',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
valueFormat: 'YYYY-MM-DD'
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
])
@@ -1555,8 +1555,8 @@
const flowType = row.flow_type || 'shipping' // 历史数据兼容
const userType = Number(userStore.info.user_type)
const entryPermission =
userType === 3 ? AUDIT_PERMISSIONS.agentActivity : AUDIT_PERMISSIONS.resourceTimeline
if (userType !== 4 && hasAuth(entryPermission)) {
userType === 3 ? AUDIT_PERMISSIONS.agentExchangeActivity : AUDIT_PERMISSIONS.exchangeEntry
if ([1, 2, 3].includes(userType) && hasAuth(entryPermission)) {
const exchangeTarget = resolveAuditResourceTarget({
userType,
resourceType: 'exchange_order',
@@ -1574,7 +1574,7 @@
resourceType: row.old_asset_type,
internalId: row.old_asset_id
})
if (oldAssetTarget)
if (oldAssetTarget && hasAuth(AUDIT_PERMISSIONS.exchangeOldAssetEntry))
actions.push({
label: '旧资产审计',
handler: () => openAuditInvestigation(oldAssetTarget),
@@ -1584,7 +1584,7 @@
resourceType: row.new_asset_type || '',
internalId: row.new_asset_id
})
if (newAssetTarget)
if (newAssetTarget && hasAuth(AUDIT_PERMISSIONS.exchangeNewAssetEntry))
actions.push({
label: '新资产审计',
handler: () => openAuditInvestigation(newAssetTarget),
@@ -1828,9 +1828,9 @@
.exchange-field-error {
width: 100%;
margin-top: 4px;
color: var(--el-color-danger);
font-size: 12px;
line-height: 1.2;
color: var(--el-color-danger);
}
}
</style>

View File

@@ -80,7 +80,7 @@
total_count: 0,
window_days: 15
})
const searchForm = reactive<ExpiringAssetQueryParams>({
const searchForm = reactive<ExpiringAssetQueryParams & { expires_range: string[] }>({
asset_type: undefined,
keyword: '',
shop_id: undefined,
@@ -88,7 +88,8 @@
days_min: undefined,
days_max: undefined,
expires_from: undefined,
expires_to: undefined
expires_to: undefined,
expires_range: []
})
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 })
@@ -166,16 +167,17 @@
}
},
{
label: '预计到期起',
prop: 'expires_from',
type: 'date',
config: { valueFormat: 'YYYY-MM-DD', clearable: true }
},
{
label: '预计到期结束',
prop: 'expires_to',
type: 'date',
config: { valueFormat: 'YYYY-MM-DD', clearable: true }
label: '预计到期起',
prop: 'expires_range',
type: 'datetimerange',
config: {
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ',
clearable: true
}
}
])
@@ -314,8 +316,9 @@
const daysMax = toInteger(searchForm.days_max)
if (daysMin !== undefined) params.days_min = daysMin
if (daysMax !== undefined) params.days_max = daysMax
if (searchForm.expires_from) params.expires_from = searchForm.expires_from
if (searchForm.expires_to) params.expires_to = searchForm.expires_to
const [expiresFrom, expiresTo] = searchForm.expires_range || []
if (expiresFrom) params.expires_from = expiresFrom
if (expiresTo) params.expires_to = expiresTo
const response = await AssetService.getExpiringAssets(params)
if (response.code === 0 && response.data) {
@@ -347,7 +350,8 @@
days_min: undefined,
days_max: undefined,
expires_from: undefined,
expires_to: undefined
expires_to: undefined,
expires_range: []
})
pagination.currentPage = 1
void loadAssets()

View File

@@ -100,15 +100,15 @@
options: () => statusOptions
},
{
label: '创建时间',
label: '起止时间',
prop: 'dateRange',
type: 'date',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DD'
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
]

View File

@@ -98,16 +98,16 @@
<ElDialog
v-model="allocateDialogVisible"
title="批量分配"
width="600px"
width="40%"
@close="handleAllocateDialogClose"
>
<ElForm
ref="allocateFormRef"
:model="allocateForm"
:rules="allocateRules"
label-width="120px"
label-width="100px"
>
<ElFormItem label="店铺" prop="to_shop_id">
<ElFormItem label="目标店铺" prop="to_shop_id">
<ElCascader
v-model="allocateForm.to_shop_id"
:options="shopCascadeOptions"
@@ -241,10 +241,10 @@
<ElDialog
v-model="recallDialogVisible"
title="批量回收"
width="600px"
width="40%"
@close="handleRecallDialogClose"
>
<ElForm ref="recallFormRef" :model="recallForm" :rules="recallRules" label-width="120px">
<ElForm ref="recallFormRef" :model="recallForm" :rules="recallRules" label-width="100px">
<ElFormItem label="选卡方式" prop="selection_type">
<ElRadioGroup v-model="recallForm.selection_type">
<ElRadio label="list">ICCID列表</ElRadio>
@@ -385,14 +385,14 @@
<ElDialog
v-model="enterpriseCardAuthorizeDialogVisible"
title="授权卡给企业"
width="640px"
width="40%"
@close="handleEnterpriseCardAuthorizeDialogClose"
>
<ElForm
ref="enterpriseCardAuthorizeFormRef"
:model="enterpriseCardAuthorizeForm"
:rules="enterpriseCardAuthorizeRules"
label-width="120px"
label-width="100px"
>
<ElFormItem label="授权企业" prop="enterprise_id">
<ElSelect
@@ -538,14 +538,14 @@
<ElDialog
v-model="enterpriseCardRecallDialogVisible"
title="回收卡授权"
width="640px"
width="40%"
@close="handleEnterpriseCardRecallDialogClose"
>
<ElForm
ref="enterpriseCardRecallFormRef"
:model="enterpriseCardRecallForm"
:rules="enterpriseCardRecallRules"
label-width="120px"
label-width="100px"
>
<ElFormItem label="授权企业" prop="enterprise_id">
<ElSelect
@@ -1001,13 +1001,6 @@
@success="handleUpdateRealnameStatusSuccess"
/>
<!-- 操作审计日志弹窗 -->
<OperationLogsDialog
v-model="operationLogsDialogVisible"
:identifier="operationLogsIdentifier"
download-permission="iot_card:download_log_file"
/>
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="iot_card"
@@ -1067,7 +1060,6 @@
import type { AssetRealnamePolicy, PackageSeriesResponse } from '@/types/api'
import type { EnterpriseItem } from '@/types/api/enterprise'
import type { AllocateCardsResponse, RecallCardsResponse } from '@/types/api/enterpriseCard'
import OperationLogsDialog from '@/components/business/OperationLogsDialog.vue'
defineOptions({ name: 'StandaloneCardList' })
@@ -1119,9 +1111,6 @@
recalled_devices: null
})
// 操作审计日志弹窗
const operationLogsDialogVisible = ref(false)
const operationLogsIdentifier = ref('')
const exportDialogVisible = ref(false)
// 套餐系列绑定相关
@@ -2323,23 +2312,12 @@
})
}
if (hasAuth('iot_card:operation_logs')) {
actions.push({
label: '操作审计日志',
handler: () => {
operationLogsIdentifier.value = row.iccid
operationLogsDialogVisible.value = true
},
type: 'primary'
})
}
const userType = Number(userStore.getUserInfo.user_type)
const auditPermission =
userType === 3
? AUDIT_PERMISSIONS.agentActivity
? AUDIT_PERMISSIONS.agentCardActivity
: userType === 4
? AUDIT_PERMISSIONS.enterpriseActivity
? AUDIT_PERMISSIONS.enterpriseCardActivity
: AUDIT_PERMISSIONS.cardEntry
if (hasAuth(auditPermission)) {
const auditTarget = resolveAuditResourceTarget({

View File

@@ -10,22 +10,6 @@
返回
</ElButton>
<h2 class="detail-title">资产分配详情</h2>
<ElButton
v-if="recordAuditTarget && hasAuth(recordAuditPermission)"
type="primary"
plain
@click="openAuditInvestigation(recordAuditTarget)"
>
{{ userType === 3 ? '活动记录' : '分配审计' }}
</ElButton>
<ElButton
v-if="assetAuditTarget && hasAuth(AUDIT_PERMISSIONS.resourceTimeline)"
type="primary"
plain
@click="openAuditInvestigation(assetAuditTarget)"
>
资产审计
</ElButton>
</div>
<!-- 详情内容 -->
@@ -41,7 +25,7 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElCard, ElButton, ElIcon, ElMessage } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
@@ -50,42 +34,14 @@
import { CardService } from '@/api/modules'
import type { AssetAllocationRecord } from '@/types/api/card'
import { formatDateTime } from '@/utils/business/format'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
defineOptions({ name: 'AssetAssignDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const userStore = useUserStore()
const loading = ref(false)
const detailData = ref<AssetAllocationRecord | null>(null)
const userType = computed(() => Number(userStore.info.user_type))
const recordAuditPermission = computed(() =>
userType.value === 3 ? AUDIT_PERMISSIONS.agentActivity : AUDIT_PERMISSIONS.resourceTimeline
)
const recordAuditTarget = computed(() => {
if (!detailData.value || userType.value === 4) return null
return resolveAuditResourceTarget({
userType: userType.value,
resourceType: 'asset_allocation_record',
internalId: detailData.value.id,
businessIdentifier: detailData.value.allocation_no
})
})
const assetAuditTarget = computed(() => {
if (!detailData.value || ![1, 2].includes(userType.value)) return null
return resolveAuditResourceTarget({
userType: userType.value,
resourceType: detailData.value.asset_type,
internalId: detailData.value.asset_id
})
})
// 详情页配置
const detailSections: DetailSection[] = [

View File

@@ -172,16 +172,16 @@
}))
},
{
label: '创建时间',
label: '起止时间',
prop: 'dateRange',
type: 'date',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
clearable: true,
rangeSeparator: '至',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
valueFormat: 'YYYY-MM-DD'
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
]
@@ -232,8 +232,10 @@
const actions: Array<{ label: string; handler: () => void; type: 'primary' }> = []
const userType = Number(userStore.info.user_type)
const entryPermission =
userType === 3 ? AUDIT_PERMISSIONS.agentActivity : AUDIT_PERMISSIONS.resourceTimeline
if (!hasAuth(entryPermission) || userType === 4) return actions
userType === 3
? AUDIT_PERMISSIONS.agentAssetAllocationActivity
: AUDIT_PERMISSIONS.assetAllocationEntry
if (!hasAuth(entryPermission) || ![1, 2, 3].includes(userType)) return actions
const recordTarget = resolveAuditResourceTarget({
userType,
resourceType: 'asset_allocation_record',
@@ -252,7 +254,7 @@
resourceType: row.asset_type,
internalId: row.asset_id
})
if (assetTarget)
if (assetTarget && hasAuth(AUDIT_PERMISSIONS.assetAllocationAssetEntry))
actions.push({
label: '资产审计',
handler: () => openAuditInvestigation(assetTarget),

View File

@@ -39,8 +39,8 @@
</ArtTable>
<!-- 修改备注对话框 -->
<ElDialog v-model="remarkDialogVisible" title="修改备注" width="500px">
<ElForm ref="remarkFormRef" :model="remarkForm" :rules="remarkRules" label-width="80px">
<ElDialog v-model="remarkDialogVisible" title="修改备注" width="40%">
<ElForm ref="remarkFormRef" :model="remarkForm" :rules="remarkRules">
<ElFormItem label="备注" prop="remark">
<ElInput
v-model="remarkForm.remark"
@@ -200,12 +200,12 @@
{
label: '授权时间',
prop: 'dateRange',
type: 'daterange',
type: 'datetimerange',
config: {
type: 'daterange',
type: 'datetimerange',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
valueFormat: 'YYYY-MM-DD'
valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
}
}
]
@@ -264,7 +264,8 @@
{
prop: 'enterprise_name',
label: '企业名称',
minWidth: 150
minWidth: 200,
showOverflowTooltip: true
},
{
prop: 'authorizer_name',
@@ -310,7 +311,7 @@
if (hasAuth('authorization_records:update_remark')) {
actions.push({
label: '编辑',
label: '修改备注',
handler: () => showRemarkDialog(row),
type: 'primary'
})

Some files were not shown because too many files have changed in this diff Show More