feat: 审计链路
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 59s

This commit is contained in:
luo
2026-08-07 18:33:37 +08:00
parent fa3d7088f9
commit b8b2854aa6
54 changed files with 5448 additions and 12 deletions

View File

@@ -0,0 +1,53 @@
## Context
本次变更横跨平台、代理和企业三类主体以及资产、订单、退款、钱包、通知等多个业务页面。OpenAPI 将关联关系明确建模为 `investigation_refs``linkage` 和稳定资源引用,并区分平台内部 ID 与代理/企业可见的业务标识。前端必须保持这些边界,不能为了补齐跳转而从名称、时间或摘要推断关联关系。
仓库目前没有已归档的基线 specs但存在通知中心和旧资产操作日志的活跃变更。本提案建立独立的审计链路能力通过明确的依赖点与它们集成不复制或覆盖其已有要求。
## Goals / Non-Goals
- Goals: 按 OpenAPI 实现 16 个只读接口的类型、查询、页面和跨视角跳转契约。
- Goals: 为平台、代理和企业提供符合各自权限与数据投影的业务审计入口。
- Goals: 统一分页、RFC3339 时间范围、枚举展示、在线保留窗口和错误处理。
- Non-Goals: 不修改后端接口、数据库、日志采集或保留策略。
- Non-Goals: 不提供恢复、重试、修改、删除、处置、封禁、任意全文搜索或导出。
- Non-Goals: 不替换现有资产操作日志组件,也不修改 C/H5 端。
## Decisions
- Decision: 新增独立审计 API 模块和共享类型按平台审计、主体活动、资金、风险、Integration、链路时间线分组暴露查询函数统一保留服务端响应包装和 nullable 字段。
- Decision: 平台业务入口使用响应中的内部稳定 ID。卡、设备、店铺、企业、订单和退款分别使用对应 `resource_type``response.data.id`;钱包通过资金时间线的 `wallet_id` 进入。
- Decision: 代理与企业入口使用响应中的稳定业务标识。卡使用 `iccid`,设备使用 `virtual_no`;代理的分配、换货、店铺和企业使用接口文档指定的业务编号。缺少稳定标识时隐藏入口。
- Decision: 前端只转发后端返回的调查引用。事件的 `event_id``actor_ref``resource_refs``request_id``correlation_id``integration_refs`,以及 Integration 的 `linkage`/`resource` 是跨视角导航的唯一数据来源。
- Decision: `request_id` 可以来自调查引用、Integration、或用户从 Access Log 明确粘贴;`correlation_id``integration_id` 只能来自接口返回的稳定字段。前端不使用 UUID 或其他方式生成这些 ID。
- Decision: 对不存在、为空或 `fidelity=false` 的引用隐藏对应跳转,禁止按名称、时间、摘要或相邻记录猜测缺失关系。
- Decision: 资源搜索严格使用 `resource_type + keyword` 精确查询,并把选中项的 `resource_type/resource_id` 原样传给资源时间线。`historical=true` 只作为历史快照命中提示。
- Decision: 列表和时间线统一消费 `page``page_size``total` 并保留 `retention` 语义。时间筛选发送含时区的 RFC3339 值,`created_to` 按接口定义为不包含该时刻;页面不展示全局在线窗口提示,也不提供归档查询假入口。
- Decision: 所有枚举筛选提交稳定 code并优先展示后端 `*_name``action`、Integration `operation` 等开放编码必须直接来自响应,不能从中文文案反推。
- Decision: 资金金额按分展示转换,权威性只认 `amount_authority.authoritative=true` 指向的业务字段;前端不得合并冲突金额或自行计算余额。
- Decision: 代理与企业活动共用展示外壳但使用独立 API。企业页面只展示主体安全投影不引入平台操作者、风险、内部原因或 before/after 字段。
- Decision: 通知目标仅在 `available=true``target_type=integration_log``target_key` 非空时跳转,并将 `target_key` 原样作为 Integration 详情的 `integration_id`。其他情况保留通知正文并提示目标不可用。
- Decision: 权限以最终菜单/按钮权限编码和服务端鉴权共同控制。平台、代理、企业不展示不属于其主体的审计入口,`401/403` 继续走共享认证与无权处理。
## Risks / Trade-offs
- 最终菜单与按钮权限编码尚未体现在 OpenAPI 中;实施前必须确认,否则只能依赖服务端 `403`,无法做到准确的入口隐藏。
- 审计事件和 Integration 模型字段较多;通过共享详情/时间线组件和严格类型避免各业务页重复实现,但不抽象为可以执行任意端点的动态页面。
- 在线保留窗口外的数据不可由这些接口查询;空状态使用“当前查询无记录”的中性表达,避免将空数据误报为无历史事件。
- 活跃通知中心变更也会修改通知导航;实施时需在其最新状态上追加 `integration_log` 规则,避免覆盖既有白名单映射。
- 旧资产操作日志与新审计中心含义相邻但数据源不同;两个入口并存并清晰命名,避免误将旧日志能力删除。
## Migration Plan
1. 确认平台、代理、企业的菜单和按钮权限编码,以及审计中心路由归属。
2. 建立 OpenAPI 对齐的类型、API 模块和共享只读展示组件。
3. 按平台审计、资金、风险、Integration、主体活动顺序接入页面。
4. 在业务页面与通知导航中增加稳定引用入口。
5. 完成角色、数据隔离、空引用、保留窗口、错误和跨视角跳转验收。
6. 若上线异常,隐藏新增路由与入口;现有业务接口和旧操作日志不受影响。
## Open Questions
- 平台审计中心、风险中心、Integration、代理资源活动和企业资源活动的最终菜单及按钮权限编码分别是什么
- 审计中心是作为一级菜单,还是归入现有系统管理/运维菜单?
- OpenAPI 未包含 `GET /api/admin/notifications/{id}/target` 的完整响应 schema是否继续以活跃通知中心变更定义的 `available/target_type/target_id/target_key` 为最终契约?

View File

@@ -0,0 +1,38 @@
# Change: 新增审计链路前端接入能力
## Why
八月迭代新增了审计事件、请求与业务链路、资金调查、风险调查、外部集成交互以及代理/企业资源活动共 16 个只读接口。当前前端尚无统一的审计调查页面、接口类型和业务入口,无法可靠消费后端返回的稳定调查引用,也容易错误地由名称、时间或页面数据自行拼接链路标识。
## What Changes
- 新增平台审计事件列表、详情、操作者时间线、资源精确搜索和资源时间线能力。
- 新增请求链路与业务关联链路时间线,并只使用接口返回或用户从 Access Log 粘贴的稳定 `request_id``correlation_id`
- 新增资金调查时间线、风险总览与明细、Integration 总览/列表/详情页面。
- 新增代理和企业资源活动入口,并按主体支持范围使用稳定业务标识和后端安全投影。
- 在卡、设备、店铺、企业、订单、退款和钱包相关列表/详情页增加角色匹配的「审计记录」入口。
- 接入通知目标到 Integration 详情的受控跳转:仅接受可用的 `integration_log` 目标。
- 统一使用后端返回的名称、状态、保留窗口语义和调查引用;禁止前端猜测或生成 `request_id``correlation_id``integration_id`
- 所有新增能力均为只读调查能力,不增加恢复、修改、删除、处置、封禁或导出操作。
## Impact
- Affected specs: `audit-chain-frontend-integration`
- Affected code:
- `src/api/modules/` 下新增审计接口模块
- `src/types/api/` 下新增审计接口类型
- `src/views/` 下审计中心、风险中心、Integration 和时间线页面
- 卡、设备、店铺、企业、订单、退款、钱包相关列表/详情页
- 路由、菜单、权限控制及通知目标导航
- API contracts:
- `GET /api/admin/audit/*`
- `GET /api/admin/agent/resource-activities/{resource_type}/{identifier}`
- `GET /api/admin/enterprise/resource-activities/{resource_type}/{identifier}`
- `GET /api/admin/notifications/{id}/target`
- Dependencies:
- 与活跃变更 `update-admin-notification-center-api` 的受控通知目标协议保持一致
- 与活跃变更 `update-admin-asset-device-signal-and-audit-logs` 的旧资产操作日志展示并存;本变更不替换旧日志接口
- Source of truth:
- `docs/产品迭代8月份/审计链路接口变更整理文档.md`
- `docs/产品迭代8月份/默认模块.openapi.json`
- Breaking changes: 无;现有业务接口路径保持不变

View File

@@ -0,0 +1,207 @@
## ADDED Requirements
### Requirement: Platform Audit Event Investigation
The admin frontend SHALL provide platform audit event list and detail views using `GET /api/admin/audit/events` and `GET /api/admin/audit/events/{event_id}`. It SHALL preserve the documented event, actor, scope, resource, result, risk, request and investigation-reference fields, use stable codes for filters, and prefer backend display names.
#### Scenario: Filter and inspect audit events
- **WHEN** an authorized platform user filters audit events by documented time, action, category, actor, source, result, risk, scope, resource or linkage parameters
- **THEN** the frontend MUST send the documented parameter names and stable code values
- **AND** it MUST render the paginated `items`, `total`, `page`, `page_size` and `retention` response
#### Scenario: Open an event detail
- **WHEN** the user opens an event returned by the list or another investigation reference
- **THEN** the frontend MUST pass its `event_id` unchanged to the detail endpoint
- **AND** it MUST display backend snapshots and names without replacing historical values with current account or resource names
### Requirement: Stable Investigation Reference Navigation
The frontend SHALL navigate between audit views only through stable identifiers returned by the APIs. `investigation_refs.actor_ref`, `resource_refs`, `request_id`, `correlation_id`, `integration_refs`, Integration `linkage`, and stable Integration `resource` references SHALL be the authoritative navigation sources.
#### Scenario: Navigate through a returned reference
- **WHEN** an audit or Integration response contains a non-empty supported investigation reference
- **THEN** the frontend MUST pass the returned identifier and type unchanged to the corresponding actor, resource, request, correlation or Integration view
#### Scenario: A reference is unavailable or unreliable
- **WHEN** the required reference is empty, absent, unsupported, or its linkage `fidelity` indicates that a relationship is unavailable
- **THEN** the frontend MUST hide or disable the related navigation action
- **AND** it MUST NOT infer a relationship from names, timestamps, summaries, adjacent rows or resource text
#### Scenario: Link identifiers are not generated by the frontend
- **WHEN** the frontend needs a `correlation_id` or `integration_id`
- **THEN** it MUST use a value returned by an API
- **AND** it MUST NOT generate, concatenate or guess the identifier
### Requirement: Actor, Resource, and Link Timelines
The admin frontend SHALL provide actor-event, resource-event, request-link and correlation-link timelines through the documented endpoints. It SHALL support an explicitly pasted Access Log `request_id`, while all other navigation values SHALL come from returned stable references.
#### Scenario: Inspect actor behavior
- **WHEN** a user follows `actor_ref.kind/id` or opens an account by its stable ID
- **THEN** the frontend MUST call `/api/admin/audit/actors/{kind}/{id}/events`
- **AND** action and resource filters MUST use stable values returned by audit data
#### Scenario: Select an exact resource result
- **WHEN** a user searches with a supported `resource_type` and exact `keyword` and selects a result
- **THEN** the frontend MUST pass `items[].resource_type/resource_id` unchanged to the resource timeline
- **AND** it MUST identify `historical=true` as a historical-snapshot match
#### Scenario: Query a request copied from Access Log
- **WHEN** a platform user explicitly pastes a request ID from Access Log
- **THEN** the frontend MAY query `/api/admin/audit/requests/{request_id}/timeline` with that exact value
- **AND** it MUST NOT claim that the endpoint scans Access Log or archived object storage
### Requirement: Retention, Pagination, and Time Semantics
All audit list and timeline views SHALL use the documented pagination and `retention` contract. Time filters SHALL be RFC3339 timestamps with timezone information, and `created_to` SHALL be treated as an exclusive upper bound.
#### Scenario: Respect the online retention window without a global prompt
- **WHEN** a response contains `retention.online_from`, `archived_before` and `timezone`
- **THEN** the frontend MUST preserve the retention semantics in its data contract without displaying a global online-window banner
- **AND** it MUST NOT interpret an empty online result as proof that no historical event exists
#### Scenario: Page through a timeline
- **WHEN** the user changes page or page size
- **THEN** the frontend MUST use `page` and `page_size`, respect the maximum page size of 100, and preserve the active filters
### Requirement: Finance Investigation Timeline
The admin frontend SHALL query `GET /api/admin/audit/finance/timeline` with any documented stable finance condition, including shop, wallet, order, payment, refund, recharge, approval, third-party trade, actor or correlation identifiers. Monetary values SHALL remain integer fen in application data, and authority SHALL follow `amount_authority`.
#### Scenario: Open finance history from a business record
- **WHEN** a user opens finance history from an order, refund, recharge, wallet or shop with a stable backend ID
- **THEN** the frontend MUST send the matching documented query parameter
- **AND** it MUST allow the server to complete related facts instead of assembling a local timeline
#### Scenario: Display an authoritative amount
- **WHEN** a finance node returns an amount and `amount_authority.authoritative=true`
- **THEN** the frontend MUST treat the referenced table and field as authoritative
- **AND** it MUST only convert integer fen for presentation and MUST NOT reconcile conflicting facts locally
### Requirement: Risk Investigation
The admin frontend SHALL provide risk overview and event-detail views using `/api/admin/audit/risks/overview` and `/api/admin/audit/risks/events`. Filters SHALL use codes returned in overview collections, and the selected time range SHALL not exceed 31 days.
#### Scenario: Drill down from a risk summary
- **WHEN** a user selects a risk, result, action, source or signal represented by the overview
- **THEN** the frontend MUST use the returned stable code for the supported event-list filter
- **AND** backend `name` values MUST be used only for display
#### Scenario: Select a range longer than 31 days
- **WHEN** the user attempts to query more than 31 days
- **THEN** the frontend MUST prevent submission and explain the maximum range
### Requirement: External Integration Investigation
The admin frontend SHALL provide Integration overview, list and detail views through the documented endpoints. It SHALL preserve provider, direction, operation, raw result, derived result category, duration, state-change, trigger, resource, linkage, attempt, content-summary, fidelity and retention fields.
#### Scenario: Apply overview filters to the list
- **WHEN** a user selects an overview provider, direction or result
- **THEN** the frontend MUST map `providers[].code` to `provider`, `directions[].code` to `direction`, `results[].code` to `result`, and `results[].category` to `result_category`
- **AND** it MUST use `name` only as display text
#### Scenario: Display Integration trend categories
- **WHEN** the overview returns trend points
- **THEN** the frontend MUST preserve the five categories `processing`, `succeeded`, `indeterminate`, `failed` and `not_sent`
- **AND** it MUST use only the documented `hour` or `day` bucket
#### Scenario: Inspect an Integration detail
- **WHEN** a user opens an `integration_id` returned by a list, event reference or notification target
- **THEN** the frontend MUST query the detail with that value unchanged
- **AND** it MUST present the capability as read-only without recovery, modification, deletion or export actions
### Requirement: Role-Specific Resource Activity
The frontend SHALL use the agent and enterprise resource-activity endpoints according to the authenticated subject. Agent activity SHALL support `iot_card`, `device`, `asset_allocation_record`, `exchange_order`, `shop` and `enterprise`; enterprise activity SHALL support only authorized `iot_card` and `device` resources.
#### Scenario: Open an agent resource activity
- **WHEN** an agent opens activity for a supported resource
- **THEN** the frontend MUST use ICCID for a card, VirtualNo for a device, or the documented stable business number for another supported resource
- **AND** it MUST NOT send or infer the agent identity or shop scope as query data
#### Scenario: Open enterprise asset activity
- **WHEN** an enterprise user opens an authorized card or device activity
- **THEN** the frontend MUST use ICCID or VirtualNo with the enterprise endpoint
- **AND** it MUST render only the subject-safe projection without platform actor, risk, internal reason or before/after fields
#### Scenario: A stable subject identifier is missing
- **WHEN** a business response does not contain the required ICCID, VirtualNo or stable business number
- **THEN** the frontend MUST hide the activity entry
- **AND** it MUST NOT substitute a display name or internal identifier intended for another subject
### Requirement: Business Audit Entries
The frontend SHALL add role-appropriate audit entries to card, device, shop, enterprise, order, refund and wallet list/detail contexts without changing existing business API URLs. Platform entries SHALL use backend internal IDs; agent and enterprise entries SHALL use their documented stable business identifiers.
#### Scenario: Open platform asset or business history
- **WHEN** a platform user opens audit history from a card, device, shop, enterprise, order or refund record
- **THEN** the frontend MUST use the corresponding `resource_type` and backend `response.data.id` with the resource timeline
#### Scenario: Open wallet or transaction history
- **WHEN** a user opens audit history for a wallet, order or refund with a stable ID
- **THEN** the frontend MUST offer the applicable finance timeline query
- **AND** the resource timeline MAY also be offered only when a stable resource reference is available
#### Scenario: Preserve existing operation logs
- **WHEN** new audit entries are introduced on asset pages
- **THEN** existing asset operation-log features MUST remain available
- **AND** the UI MUST distinguish the existing operation logs from the new cross-system audit investigation
### Requirement: Controlled Notification Integration Target
The frontend SHALL resolve a notification through `GET /api/admin/notifications/{id}/target` before opening an Integration detail. It SHALL open the detail only when the target is available, has `target_type=integration_log`, and contains a non-empty `target_key`.
#### Scenario: Open an Integration notification
- **WHEN** target resolution returns `available=true`, `target_type=integration_log`, and a non-empty `target_key`
- **THEN** the frontend MUST pass `target_key` unchanged as the Integration `integration_id`
- **AND** it MUST open the controlled internal Integration detail route
#### Scenario: Notification target cannot be used
- **WHEN** the target is unavailable, has another type, lacks `target_key`, or maps to no registered route
- **THEN** the frontend MUST not navigate or execute an arbitrary URL
- **AND** it MUST preserve the notification content and show a safe unavailable-target message
### Requirement: Audit Authorization and Read-Only Boundary
The frontend SHALL enforce the final platform, agent and enterprise route and action permissions, while preserving backend authentication, authorization and data-isolation enforcement. All capabilities in this change SHALL remain read-only.
#### Scenario: A subject lacks audit permission
- **WHEN** the current subject lacks the configured permission for an audit page or business entry
- **THEN** the frontend MUST hide or disable that page or entry and MUST NOT call the endpoint as a fallback
#### Scenario: An audit request is rejected
- **WHEN** an endpoint returns `400`, `401`, `403` or `500`
- **THEN** the frontend MUST use shared validation, authentication, authorization and server-error handling
- **AND** it MUST preserve the current investigation state when retrying would be unsafe or misleading
#### Scenario: A user inspects an audit record
- **WHEN** any audit, risk, finance, Integration or subject-activity view is displayed
- **THEN** the UI MUST NOT provide mutation, recovery, deletion, risk-disposition, blocking or export actions

View File

@@ -0,0 +1,44 @@
## 1. Contract and Shared Infrastructure
- [x] 1.1 根据 `默认模块.openapi.json` 建立审计事件、调查引用、资源、保留窗口、链路节点、资金节点、风险和 Integration 的 TypeScript 类型。
- [x] 1.2 新增平台审计、主体资源活动、资金、风险、Integration 和链路时间线 API 方法,保持文档参数名、枚举和 nullable 语义。
- [x] 1.3 建立共享的分页、RFC3339 时间范围、保留窗口、枚举名称和 API 错误展示能力。
- [x] 1.4 建立只读事件详情、资源摘要、调查引用和时间线节点组件,不提供写操作或导出能力。
## 2. Platform Audit Center
- [x] 2.1 实现审计事件列表及文档定义的时间、动作、类别、操作者、来源、结果、风险、范围、资源和链路筛选。
- [x] 2.2 实现事件详情,展示事件、操作者、资源快照、结果、风险和元数据,并按非空 `investigation_refs` 提供跳转。
- [x] 2.3 实现操作者行为时间线,使用 `actor_ref.kind/id` 和稳定 code 筛选。
- [x] 2.4 实现资源精确搜索及资源时间线,原样使用选中项的 `resource_type/resource_id` 并提示历史快照命中。
- [x] 2.5 实现请求和业务关联时间线,支持从返回引用进入,以及明确粘贴 Access Log `request_id` 的查询入口。
## 3. Specialized Investigations
- [x] 3.1 实现资金调查时间线和全部文档筛选条件,以分为数据单位并展示金额权威来源。
- [x] 3.2 实现风险总览和风险事件明细,按总览返回的稳定 code 回填筛选,并限制最长 31 天时间范围。
- [x] 3.3 实现 Integration 总览、列表和详情,覆盖 provider、direction、result、result_category、趋势、尝试、内容摘要、保真度和关联字段。
- [x] 3.4 实现各调查视角之间基于 `investigation_refs``linkage` 和稳定资源引用的受控跳转。
## 4. Subject Activity and Business Entries
- [x] 4.1 实现代理资源活动页面,支持卡、设备、资产分配记录、换货单、店铺和企业的稳定业务标识。
- [x] 4.2 实现企业资源活动页面,仅支持当前授权卡和设备并只渲染主体安全投影。
- [x] 4.3 在卡、设备、店铺、企业、订单、退款和钱包列表/详情增加角色匹配的「审计记录」入口。
- [x] 4.4 缺少内部 ID、ICCID、VirtualNo 或业务编号时隐藏入口,不使用页面文本或其他字段推断。
- [x] 4.5 保留现有资产操作日志入口,并通过命名与说明区分旧操作日志和新审计调查能力。
## 5. Notification, Routes, and Permissions
- [x] 5.1 增加审计中心、风险中心、Integration 和共享时间线的路由与菜单配置。
- [x] 5.2 接入最终确认的平台、代理、企业菜单/按钮权限编码,隐藏越权页面和业务入口。
- [x] 5.3 在通知目标导航中仅对可用 `integration_log` 目标开放 Integration 详情,并原样使用 `target_key`
- [x] 5.4 对未知、不可用或缺失目标保留安全提示,不执行任意 URL 或推测跳转。
## 6. Verification
- [ ] 6.1 为 API 参数序列化、枚举、nullable 字段、分页和 RFC3339 时间范围增加单元测试。
- [ ] 6.2 为稳定引用跳转、空引用隐藏、`fidelity=false`、通知 Integration 目标和禁止生成链路 ID 增加测试。
- [ ] 6.3 验证平台、代理、企业角色可见性、服务端数据隔离、企业安全投影以及 `401/403/400/500` 处理。
- [ ] 6.4 验证保留窗口、空数据、最长 31 天风险查询、资金金额单位和 Integration 五类趋势状态。
- [ ] 6.5 完成 16 个接口的联调回归,并确认现有业务 URL、通知中心和旧资产操作日志未被破坏。

View File

@@ -0,0 +1,52 @@
## Context
审计调查横跨平台、代理和企业三种身份,以及事件、资源、请求、业务链路、资金和外部交互等多种事实视角。后端通过内部资源 ID、主体安全 identifier、`investigation_refs`、Integration `linkage``fidelity` 明确表达可导航关系;前端的职责是原样传递这些稳定引用,而不是补全关系。
现有实现已经形成 `AuditInvestigationDrawer``AuditResourceSearchDialog` 和全局控制器,但业务页面仍需逐一核对入口覆盖、角色映射和降级规则。本设计将导航判断集中到共享目标解析层,展示仍由只读组件负责。
## Goals / Non-Goals
- Goals: 用一个类型安全的目标模型表达所有允许的审计调查入口。
- Goals: 完成文档中资产、组织、账号、交易、资金和调查节点的导航矩阵。
- Goals: 保证平台、代理和企业仅调用各自允许的接口,并隐藏缺少稳定参数的入口。
- Goals: 使用弹窗/右侧抽屉承载续查,锁定底层滚动并支持分页与空状态。
- Non-Goals: 不修改后端链路生成、数据库、留存或授权规则。
- Non-Goals: 不提供恢复、重试、处置、封禁、修改、删除或导出。
- Non-Goals: 不把旧 operation log 合并进新审计链路。
- Non-Goals: 不从 Access Log、历史队列、自由文本或时间邻近关系自动发现链路。
## Decisions
- Decision: 使用判别联合类型描述 `event/actor/resource/request/correlation/finance/integration/agent/enterprise` 调查目标;调用方只能提交该目标模型,不能提交任意 URL。
- Decision: 使用全局调查宿主承载共享抽屉和精确资源弹窗。业务页面操作只负责构造目标,不各自维护弹窗、加载和滚动状态。
- Decision: 独立的审计中心列表与详情可以保留路由;跨视角时间线和关联详情使用抽屉。移除旧时间线路由、别名和所有指向它的跳转。
- Decision: 平台资源入口优先使用业务响应中的内部稳定 ID。只有调用上下文没有资源 ID、但明确持有 `iot_card/device/shop/order/refund` 的业务 Key 时,才进入精确资源搜索。
- Decision: 精确资源搜索不展示 `resource_id` 或身份快照中的内部关联 ID。候选项只展示按资源类型白名单选择的业务字段选中后在内部原样使用返回的 `resource_type/resource_id`
- Decision: 代理使用 ICCID、VirtualNo、分配单号、换货单号、店铺编号或企业编号企业只使用当前授权卡的 ICCID 或设备 VirtualNo。主体身份和范围不作为前端参数。
- Decision: `investigation_refs` 是调查节点续查的权威来源。字段缺失、目标与当前详情相同或 `fidelity=false` 时隐藏对应操作。
- Decision: 请求和业务链路 ID 默认不展示原值;仅在明确的超级管理员开发调查上下文中允许复制真实 request ID且不得由前端生成。
- Decision: 资金目标只提交一个已知稳定条件,其他订单、支付、退款、充值和钱包关联由服务端解析;金额始终按分保存在应用数据中。
- Decision: `401/403`、授权撤销或主体资源不存在时显示中性“活动不可用”,不回退平台接口、精确资源搜索或旧 operation log。
- Decision: 所有抽屉使用 `append-to-body`、modal 和 scroll lock关闭后销毁内容分页切换保留目标和筛选切换目标时清空旧数据。
## Risks / Trade-offs
- 业务页面数量多,容易出现入口漏接或使用错误字段。通过集中目标解析函数和表驱动测试覆盖文档矩阵。
- 全局抽屉减少重复代码,但必须防止上一目标的数据闪现。打开新目标前先清空状态,并以当前请求标识忽略过期响应。
- 精确资源搜索结果来自当前表或历史快照;仅展示白名单字段会减少调试信息,但可以避免泄露内部 ID 和不稳定结构。
- 本变更与 `add-audit-chain-frontend-integration` 修改同一组件目录。实施前必须以其最新状态为基线,避免覆盖已完成的 UI 调整。
## Migration Plan
1. 固化共享调查目标类型、控制器和角色解析函数,并为现有入口提供兼容适配。
2. 将仍指向旧时间线路由的入口切换为全局调查宿主,确认无引用后删除旧页面、路由和别名。
3. 按文档矩阵依次接入资产组织页、账号交易页、资金页和调查节点续查。
4. 收紧精确搜索触发条件、主体视角和空标识降级规则。
5. 完成角色矩阵、参数原样传递、禁止推断、弹窗滚动和过期请求测试。
6. 若出现回归,可按业务模块隐藏新入口;不恢复任意 URL 跳转或 ID 推断逻辑。
## Open Questions
- 账号、代理充值、资产分配、换货、店铺资金概况等尚未接入页面的最终按钮权限是否沿用资源时间线/资金时间线权限,还是增加业务入口专用权限?
- 超级管理员是否需要一个显式的 Access Log request ID 粘贴入口;若不需要,前端应完全移除手工 request 查询。
- `historical=true` 在产品要求隐藏“匹配来源”后,是否仍需用非字段式的警告图标提示资源可能已删除?

View File

@@ -0,0 +1,41 @@
## 实施差异核对
### 已接入矩阵
| 来源 | 平台视角 | 代理视角 | 企业视角 | 稳定字段 |
| --- | --- | --- | --- | --- |
| IoT 卡列表/统一资产详情 | 资源时间线 | 主体活动 | 主体活动 | `id` / `iccid` |
| 设备列表/统一资产详情 | 资源时间线 | 主体活动 | 主体活动 | `id` / `virtual_no` |
| 设备绑定卡 | 卡资源时间线、绑定资源时间线 | 卡主体活动 | 卡主体活动 | `card_id` / `iccid` |
| 资产分配列表/详情 | 分配记录、资产资源时间线 | 分配活动 | 不提供 | `id``asset_id` / `allocation_no` |
| 换货列表/详情 | 换货记录、旧/新资产时间线 | 换货活动 | 不提供 | `id`、资产 ID / `exchange_no` |
| 店铺列表 | 店铺资源、店铺资金链路 | 店铺活动 | 不提供 | `id` / `shop_no` |
| 企业列表 | 企业资源 | 企业活动 | 不提供 | `id` / `enterprise_no` |
| 账号列表 | 账号资源 | 不提供 | 不提供 | `id` |
| 订单列表/详情 | 订单资源、订单资金链路 | 不提供 | 不提供 | `id``order_id` |
| 退款列表/详情 | 退款资源、退款资金链路、审批资源 | 不提供 | 不提供 | `id``refund_id``approval_instance_id` |
| 代理充值列表/详情 | 充值资源、充值资金链路、审批资源 | 不提供 | 不提供 | `id``recharge_id``approval_instance_id` |
| 资产钱包 | 钱包资金链路、资产资源 | 不提供 | 不提供 | `wallet_id``asset_type + asset_id` |
| 店铺资金概况 | 店铺资金链路 | 不提供 | 不提供 | `shop_id` |
所有入口先独立校验自身权限和完整参数;一个入口缺字段不会隐藏同一行的其他有效入口。平台资源只使用非零内部 ID代理和企业活动只使用文档规定的业务标识。
### 共享调查与降级
- 事件、操作者、资源、请求、业务链路、资金、Integration、代理活动和企业活动均由全局调查宿主打开。
- 精确注册资源只在调查引用缺少 `resource_id`、但包含受支持类型及业务 Key 时出现;业务列表已有内部 ID 时不再提供搜索按钮。
- 精确搜索限制为 `iot_card/device/shop/order/refund`,候选项不显示 Registry ID 或快照内部关联 ID。
- 目标切换会清空旧内容并忽略过期响应;关闭会销毁内容,抽屉和弹窗锁定底层滚动。
- 缺字段、空白值、零值、不支持的主体资源类型和撤销授权均不推断或回退到其他视角。
### 受响应字段限制的入口
- 钱包流水响应只有 `asset_identifier`,没有完整的 `asset_type + asset_id`,因此不从流水行推断资产审计目标。
- 当前路由中没有独立的账号详情和店铺详情页面,对应入口保留在列表。
- 风险/链路/资金节点抽屉内部不增加继续调查按钮:产品要求移除弹窗内无意义按钮;事件详情仍使用权威 `investigation_refs` 提供跨视角入口。
### 路由核对
- 独立的审计事件和外部交互列表/详情路由保留。
- 操作者、资源、请求、业务链路、资金及主体活动不再使用独立时间线路由或任意 URL 跳转。
- 审计中心顶级菜单排列在财务管理之后。

View File

@@ -0,0 +1,40 @@
# Change: 对齐审计跨视角调查与前端导航契约
## Why
现有审计中心已经接入事件、资源、链路、资金、风险和外部交互等只读查询,但业务页面入口、调查节点续查、平台/代理/企业视角以及缺失标识时的降级行为仍缺少统一约束。若各页面继续自行拼装目标参数,容易把内部 ID、业务 Key、主体安全标识或链路 ID 混用,并产生越权回退、错误串链和重复页面。
本变更以《跨视角调查与前端导航契约.md》为导航事实来源在现有 `add-audit-chain-frontend-integration` 能力之上建立统一的跨视角导航规范和复用组件。
## What Changes
- 建立统一的审计调查目标模型和导航控制器,覆盖事件详情、操作者行为、资源审计、请求链路、业务链路、资金链路、外部交互以及代理/企业资源活动。
- 调查时间线、链路和外部交互续查统一使用可复用抽屉或弹窗,不再新增独立时间线跳转页面;同一目标不重复打开自身详情。
- 按“内部稳定 ID → 主体安全业务标识 → 返回的调查引用 → 精确资源搜索 → 隐藏入口”的顺序解析入口,禁止从名称、摘要、时间、编号前缀或邻近记录推断关联。
- 对齐资产、组织、账号、订单、退款、充值、钱包和资金页面的逐行入口矩阵,并按平台、代理、企业身份选择不同接口和参数。
- 平台仅在缺少内部资源 ID、但持有允许搜索的 Registry Key 时调用精确资源搜索;零命中或多命中均保留用户选择,不自动猜测。
- 所有弹窗和抽屉只显示业务可读字段,内部资源 ID、request/correlation 等技术标识仅作为查询参数使用,除明确的开发调查场景外不直接展示。
- 统一处理空引用、`fidelity=false`、历史快照、授权撤销、归档窗口和接口拒绝,不回退旧日志、平台接口或其他主体接口。
- 补充跨页面入口矩阵、参数映射、角色隔离和禁止推断的自动化测试。
## Impact
- Affected specs: `audit-cross-view-navigation`
- Depends on: `add-audit-chain-frontend-integration`
- Affected code:
- `src/components/business/audit/` 调查抽屉、精确资源弹窗和全局导航控制器
- `src/utils/business/auditNavigation.ts` 角色与目标解析
- 审计事件、风险、外部交互页面的调查入口
- 卡、设备、设备卡槽、统一资产、分配、换货、店铺、企业、账号、订单、退款、充值、钱包及资金列表/详情
- 路由、权限和通知目标导航
- API contracts:
- `GET /api/admin/audit/*`
- `GET /api/admin/agent/resource-activities/{resource_type}/{identifier}`
- `GET /api/admin/enterprise/resource-activities/{resource_type}/{identifier}`
- 现有业务列表与详情接口返回的稳定字段
- Source of truth:
- `docs/产品迭代8月份/跨视角调查与前端导航契约.md`
- `docs/产品迭代8月份/默认模块.openapi.json`
- Breaking changes:
- 移除旧的独立审计时间线路由及其导航方式,统一改为抽屉/弹窗调查。
- 入口缺少契约要求的稳定标识时将被隐藏,不再容忍前端推断或降级调用其他视角接口。

View File

@@ -0,0 +1,124 @@
## ADDED Requirements
### Requirement: Shared Cross-View Investigation Surface
The frontend SHALL open actor, resource, request, correlation, finance, Integration, agent-activity and enterprise-activity investigations through reusable modal or drawer components. It SHALL NOT require a standalone timeline route for these cross-view investigations.
#### Scenario: Open an investigation from a business row
- **WHEN** an authorized user selects an audit or activity action from a supported business row
- **THEN** the frontend MUST construct a typed investigation target from the row's documented stable fields
- **AND** it MUST open the shared investigation surface without navigating to a standalone timeline page
#### Scenario: Replace an open investigation target
- **WHEN** the user opens a second investigation after another target was loaded
- **THEN** the shared surface MUST clear stale content before loading the new target
- **AND** a late response from the previous target MUST NOT replace the current result
### Requirement: Deterministic Investigation Target Resolution
The frontend SHALL resolve investigation targets in this order: a platform internal stable ID for a platform resource timeline, a documented subject-safe business identifier for agent or enterprise activity, an explicit API investigation reference, an exact Registry resource-key search when no internal resource ID exists, or no entry. It SHALL NOT infer a target from display text or proximity.
#### Scenario: A platform resource ID is available
- **WHEN** a platform business response contains the documented non-zero internal resource ID
- **THEN** the frontend MUST call the resource timeline with the mapped resource type and that ID
- **AND** it MUST NOT search by the display name or business Key to replace the available ID
#### Scenario: Only a supported Registry Key is available
- **WHEN** a platform investigation reference contains a supported resource type and business Key but no resource ID
- **THEN** the frontend MUST use the exact resource search endpoint
- **AND** zero or multiple matches MUST remain unresolved until the user makes an explicit choice
#### Scenario: No stable target exists
- **WHEN** the required internal ID, subject identifier or explicit investigation reference is missing
- **THEN** the frontend MUST hide or disable the entry
- **AND** it MUST NOT infer a relationship from a name, summary, timestamp, identifier prefix, adjacent record or current-account profile
### Requirement: Exact Registered Resource Selection
The frontend SHALL support exact Registry lookup only for `iot_card`, `device`, `shop`, `order` and `refund`. It SHALL pass the selected candidate's returned `resource_type/resource_id` unchanged to the resource timeline while keeping internal identifiers out of the presentation.
#### Scenario: Present an exact resource candidate
- **WHEN** exact lookup returns a candidate with `identity_snapshot`
- **THEN** the frontend MUST display only a resource-type-specific whitelist of business-readable fields
- **AND** it MUST NOT display `resource_id` or snapshot fields that are internal IDs or internal foreign keys
#### Scenario: Select a historical candidate
- **WHEN** a user selects a candidate with `historical=true` and a stable returned resource ID
- **THEN** the frontend MUST use the returned type and ID without modification
- **AND** it MUST NOT claim that the resource still exists in the current business table
### Requirement: Role-Isolated Resource Activity Navigation
The frontend SHALL choose platform, agent or enterprise resource investigation APIs solely from the authenticated subject and the documented source-page fields. Agent and enterprise navigation SHALL NOT fall back to platform audit or Registry search.
#### Scenario: Open agent activity
- **WHEN** an agent opens a supported card, device, allocation, exchange, shop or enterprise activity
- **THEN** the frontend MUST use the documented ICCID, VirtualNo or business number with the agent activity endpoint
- **AND** it MUST NOT send agent identity or shop scope as proof of authorization
#### Scenario: Open enterprise activity
- **WHEN** an enterprise user opens activity for a currently authorized card or device
- **THEN** the frontend MUST use ICCID or VirtualNo with the enterprise activity endpoint
- **AND** it MUST NOT use the enterprise route parameter or a platform internal ID as authorization evidence
#### Scenario: Subject activity becomes unavailable
- **WHEN** authorization is revoked, the resource is out of scope, or the subject endpoint rejects access
- **THEN** the frontend MUST present an activity-unavailable state
- **AND** it MUST NOT retry through a platform endpoint, Registry search or legacy operation log
### Requirement: Business and Finance Entry Matrix
The frontend SHALL implement the documented row-level navigation matrix for asset, organization, account, order, refund, recharge, wallet and fund-summary contexts. Each action SHALL be independently visible only when its complete stable parameter set and required permission are available.
#### Scenario: Open resource and finance investigations independently
- **WHEN** a business record contains both a resource ID and a stable finance condition
- **THEN** the frontend MUST offer the applicable resource audit and finance investigation actions independently
- **AND** failure or absence of one parameter set MUST NOT suppress the other valid action
#### Scenario: Let the server resolve finance relationships
- **WHEN** the frontend opens finance history from an order, refund, recharge, wallet or shop
- **THEN** it MUST submit only the stable condition explicitly returned by the source business API
- **AND** it MUST NOT derive or supplement payment, refund, wallet, approval or third-party identifiers from text or number prefixes
### Requirement: Investigation Reference Continuation
The frontend SHALL treat `investigation_refs` and documented Integration references as the authoritative sources for continuing from audit, risk, finance and link nodes. It SHALL expose only actions whose complete required reference is present and reliable.
#### Scenario: Continue from a complete reference
- **WHEN** a node contains a complete actor, resource, request, correlation or Integration reference
- **THEN** the frontend MUST pass its type and identifier unchanged to the matching shared investigation surface
#### Scenario: Suppress unavailable or self-referential navigation
- **WHEN** a reference is incomplete, its fidelity is false, or its event target is the detail already being displayed
- **THEN** the frontend MUST hide the corresponding action
- **AND** it MUST NOT create a substitute identifier or reopen the same detail
### Requirement: Read-Only Presentation and Scroll Isolation
All shared investigation surfaces SHALL remain read-only, hide non-business internal identifiers by default, and isolate modal scrolling from the underlying page.
#### Scenario: Display an investigation surface
- **WHEN** a modal or drawer is open
- **THEN** it MUST use a modal overlay, append to the document body, and lock underlying-page scrolling
- **AND** it MUST remain usable within its own scroll container at supported viewport sizes
#### Scenario: Inspect a read-only result
- **WHEN** audit, activity, finance, link or Integration data is displayed
- **THEN** the UI MUST NOT expose mutation, recovery, retry, deletion, disposition, blocking or export actions
- **AND** raw internal IDs MUST be retained only as navigation parameters unless an explicitly authorized developer-investigation workflow requires display

View File

@@ -0,0 +1,44 @@
## 1. Contract Audit and Shared Navigation
- [ ] 1.1 建立文档导航矩阵与当前页面入口的差异清单,覆盖资产组织、账号交易、资金和调查节点。
- [ ] 1.2 完善共享调查目标判别联合类型覆盖事件、操作者、资源、请求、业务链路、资金、Integration、代理和企业活动。
- [ ] 1.3 将共享导航控制器改为唯一弹窗/抽屉打开入口,并处理目标切换、请求竞态、分页和关闭清理。
- [ ] 1.4 全局搜索并移除旧时间线路由、路由别名、页面和任意 URL 式审计跳转。
## 2. Stable Reference and Search Rules
- [ ] 2.1 实现平台内部 ID、主体业务 identifier、调查引用、资源 Key 搜索和隐藏入口的优先级解析。
- [ ] 2.2 收紧精确资源搜索,仅允许 `iot_card/device/shop/order/refund` 和文档规定的精确业务 Key。
- [ ] 2.3 精确资源结果只展示资源类型白名单业务字段,隐藏 `resource_id` 及快照内部关联 ID并原样使用返回目标打开资源时间线。
- [ ] 2.4 对零命中、多命中、历史快照和缺少稳定资源 ID 分别提供不推断的降级状态。
- [ ] 2.5 对空引用、同目标引用和 `fidelity=false` 隐藏对应调查操作。
## 3. Asset and Organization Entry Matrix
- [ ] 3.1 对齐卡、设备、设备卡槽和统一资产详情的平台/代理/企业入口与稳定字段映射。
- [ ] 3.2 对齐资产分配、换货、店铺和企业列表/详情入口及角色范围。
- [ ] 3.3 对齐企业卡和企业设备列表入口,确保企业只调用主体活动接口且不使用路由企业 ID 证明授权。
- [ ] 3.4 缺少内部 ID、ICCID、VirtualNo 或业务编号时隐藏对应入口,不回退其他视角。
## 4. Account, Transaction, and Finance Entry Matrix
- [ ] 4.1 接入账号、店铺、企业、订单和退款的资源审计入口。
- [ ] 4.2 接入订单、退款、代理充值、资产钱包、店铺资金概况和钱包流水的资金链路入口。
- [ ] 4.3 对审批实例、钱包资源和流水资产引用实行独立字段完整性判断,缺少一组字段不影响其他有效入口。
- [ ] 4.4 资金查询仅传入当前业务返回的稳定条件,不由前端补齐 Payment、Refund、钱包或第三方流水关联。
## 5. Investigation Node Continuation
- [ ] 5.1 从风险、事件、资源、链路、资金和 Integration 节点按 `investigation_refs` 打开共享调查抽屉。
- [ ] 5.2 支持事件、操作者、资源、请求、业务链路和外部交互的非空引用,并抑制跳回当前详情的无意义入口。
- [ ] 5.3 请求/业务链路节点保留 `record_source``reference_only` 事实边界,不把投递成功展示为业务成功。
- [ ] 5.4 通知 Integration 目标继续使用受控 target 解析,不接受任意 URL 或数据库主键替代稳定 Integration ID。
## 6. Authorization, Presentation, and Verification
- [ ] 6.1 按平台、代理、企业及按钮权限验证所有入口可见性和接口选择。
- [ ] 6.2 验证抽屉 append-to-body、遮罩、底层滚动锁定、关闭销毁、分页和移动端布局。
- [ ] 6.3 为目标解析、参数原样传递、空值隐藏、同目标抑制、`fidelity=false` 和禁止推断增加单元测试。
- [ ] 6.4 为文档中资产组织和账号资金导航矩阵增加表驱动测试。
- [ ] 6.5 验证 `400/401/403/404/500`、授权撤销、历史快照、归档窗口和过期异步响应处理。
- [ ] 6.6 回归现有审计中心、风险中心、外部交互、通知导航和旧 operation log确认只读边界未被破坏。

View File

@@ -0,0 +1,121 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import ts from 'typescript'
async function importAuditNavigation() {
const tempDir = await mkdtemp(join(tmpdir(), 'audit-navigation-'))
const bundlePath = join(tempDir, 'auditNavigation.mjs')
const source = await readFile('src/utils/business/auditNavigation.ts', 'utf8')
const transpiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022
}
})
await writeFile(bundlePath, transpiled.outputText, 'utf8')
return {
module: await import(pathToFileURL(bundlePath).href),
cleanup: () => rm(tempDir, { recursive: true, force: true })
}
}
test('resource targets follow the platform, agent and enterprise stable-reference matrix', async () => {
const { module, cleanup } = await importAuditNavigation()
const { resolveAuditResourceTarget } = module
const cases = [
{
name: 'platform uses the internal resource id',
input: {
userType: 2,
resourceType: 'iot_card',
internalId: 8020,
businessIdentifier: 'iccid'
},
expected: { mode: 'resource', resourceType: 'iot_card', id: '8020' }
},
{
name: 'agent uses the trimmed subject identifier',
input: {
userType: 3,
resourceType: 'iot_card',
internalId: 8020,
businessIdentifier: ' 8986112520903885262 '
},
expected: { mode: 'agent', resourceType: 'iot_card', id: '8986112520903885262' }
},
{
name: 'enterprise uses a device identifier',
input: {
userType: 4,
resourceType: 'device',
internalId: 9,
businessIdentifier: ' SN-001 '
},
expected: { mode: 'enterprise', resourceType: 'device', id: 'SN-001' }
},
{
name: 'enterprise cannot use organization resources',
input: { userType: 4, resourceType: 'shop', businessIdentifier: 'SHOP-1' },
expected: null
},
{
name: 'agent cannot infer an unsupported business resource',
input: { userType: 3, resourceType: 'refund', businessIdentifier: 'RF-1' },
expected: null
},
{
name: 'platform hides a missing internal id',
input: { userType: 2, resourceType: 'order', businessIdentifier: 'ORD-1' },
expected: null
},
{
name: 'platform hides zero ids',
input: { userType: 1, resourceType: 'device', internalId: 0 },
expected: null
},
{
name: 'platform hides whitespace ids',
input: { userType: 1, resourceType: 'device', internalId: ' ' },
expected: null
}
]
try {
for (const scenario of cases) {
assert.deepEqual(resolveAuditResourceTarget(scenario.input), scenario.expected, scenario.name)
}
} finally {
await cleanup()
}
})
test('finance targets preserve only complete backend-provided conditions', async () => {
const { module, cleanup } = await importAuditNavigation()
const { resolveFinanceAuditTarget } = module
const cases = [
['numeric business id', 'order_id', 72, { mode: 'finance', field: 'order_id', value: 72 }],
[
'trimmed business number',
'refund_no',
' RF20260807171319801844 ',
{ mode: 'finance', field: 'refund_no', value: 'RF20260807171319801844' }
],
['missing value', 'wallet_id', undefined, null],
['zero value', 'payment_id', 0, null],
['whitespace value', 'correlation_id', ' ', null]
]
try {
for (const [name, field, value, expected] of cases) {
assert.deepEqual(resolveFinanceAuditTarget(field, value), expected, name)
}
} finally {
await cleanup()
}
})

View File

@@ -2,6 +2,7 @@
<ChunkErrorBoundary> <ChunkErrorBoundary>
<ElConfigProvider size="default" :locale="locales[language]" :z-index="3000"> <ElConfigProvider size="default" :locale="locales[language]" :z-index="3000">
<RouterView></RouterView> <RouterView></RouterView>
<AuditInvestigationHost />
</ElConfigProvider> </ElConfigProvider>
</ChunkErrorBoundary> </ChunkErrorBoundary>
</template> </template>
@@ -17,6 +18,7 @@
import { checkStorageCompatibility } from '@/utils' import { checkStorageCompatibility } from '@/utils'
import ChunkErrorBoundary from '@/components/core/others/ChunkErrorBoundary.vue' import ChunkErrorBoundary from '@/components/core/others/ChunkErrorBoundary.vue'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
import AuditInvestigationHost from '@/components/business/audit/AuditInvestigationHost.vue'
const userStore = useUserStore() const userStore = useUserStore()
const { language } = storeToRefs(userStore) const { language } = storeToRefs(userStore)

124
src/api/modules/audit.ts Normal file
View File

@@ -0,0 +1,124 @@
import { BaseService } from '../BaseService'
import type {
AuditActorEventQuery,
AuditActorKind,
AuditEventDetail,
AuditEventPage,
AuditEventQuery,
AuditFinanceQuery,
AuditFinanceTimelinePage,
AuditLinkTimeline,
AuditResourceSearchPage,
AuditResourceSearchQuery,
AuditResourceTimelineQuery,
AuditRiskEventPage,
AuditRiskEventQuery,
AuditRiskOverview,
AuditRiskQuery,
AuditSubjectActivityPage,
AuditSubjectActivityQuery,
AuditSubjectResourceType,
BaseResponse,
IntegrationDetailResponse,
IntegrationListPage,
IntegrationOverview,
IntegrationQuery
} from '@/types/api'
export class AuditService extends BaseService {
static getEvents(params?: AuditEventQuery): Promise<BaseResponse<AuditEventPage>> {
return this.get('/api/admin/audit/events', params)
}
static getEventDetail(eventId: string): Promise<BaseResponse<AuditEventDetail>> {
return this.get(`/api/admin/audit/events/${encodeURIComponent(eventId)}`)
}
static getActorEvents(
kind: AuditActorKind,
id: string,
params?: AuditActorEventQuery
): Promise<BaseResponse<AuditEventPage>> {
return this.get(
`/api/admin/audit/actors/${encodeURIComponent(kind)}/${encodeURIComponent(id)}/events`,
params
)
}
static searchResources(
params: AuditResourceSearchQuery
): Promise<BaseResponse<AuditResourceSearchPage>> {
return this.get('/api/admin/audit/resources/search', params)
}
static getResourceTimeline(
resourceType: string,
resourceId: string,
params?: AuditResourceTimelineQuery
): Promise<BaseResponse<AuditEventPage>> {
return this.get(
`/api/admin/audit/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/timeline`,
params
)
}
static getRequestTimeline(requestId: string): Promise<BaseResponse<AuditLinkTimeline>> {
return this.get(`/api/admin/audit/requests/${encodeURIComponent(requestId)}/timeline`)
}
static getCorrelationTimeline(correlationId: string): Promise<BaseResponse<AuditLinkTimeline>> {
return this.get(`/api/admin/audit/correlations/${encodeURIComponent(correlationId)}/timeline`)
}
static getFinanceTimeline(
params: AuditFinanceQuery
): Promise<BaseResponse<AuditFinanceTimelinePage>> {
return this.get('/api/admin/audit/finance/timeline', params)
}
static getRiskOverview(params?: AuditRiskQuery): Promise<BaseResponse<AuditRiskOverview>> {
return this.get('/api/admin/audit/risks/overview', params)
}
static getRiskEvents(params?: AuditRiskEventQuery): Promise<BaseResponse<AuditRiskEventPage>> {
return this.get('/api/admin/audit/risks/events', params)
}
static getIntegrationOverview(
params?: IntegrationQuery & { bucket?: 'hour' | 'day' }
): Promise<BaseResponse<IntegrationOverview>> {
return this.get('/api/admin/audit/integrations/overview', params)
}
static getIntegrations(params?: IntegrationQuery): Promise<BaseResponse<IntegrationListPage>> {
return this.get('/api/admin/audit/integrations', params)
}
static getIntegrationDetail(
integrationId: string
): Promise<BaseResponse<IntegrationDetailResponse>> {
return this.get(`/api/admin/audit/integrations/${encodeURIComponent(integrationId)}`)
}
static getAgentResourceActivities(
resourceType: AuditSubjectResourceType,
identifier: string,
params?: AuditSubjectActivityQuery
): Promise<BaseResponse<AuditSubjectActivityPage>> {
return this.get(
`/api/admin/agent/resource-activities/${encodeURIComponent(resourceType)}/${encodeURIComponent(identifier)}`,
params
)
}
static getEnterpriseResourceActivities(
resourceType: Extract<AuditSubjectResourceType, 'iot_card' | 'device'>,
identifier: string,
params?: AuditSubjectActivityQuery
): Promise<BaseResponse<AuditSubjectActivityPage>> {
return this.get(
`/api/admin/enterprise/resource-activities/${encodeURIComponent(resourceType)}/${encodeURIComponent(identifier)}`,
params
)
}
}

View File

@@ -37,6 +37,7 @@ export { ExportTaskService } from './exportTask'
export { OrderPackageInvalidateTaskService } from './orderPackageInvalidateTask' export { OrderPackageInvalidateTaskService } from './orderPackageInvalidateTask'
export { BulkPurchaseService } from './bulkPurchase' export { BulkPurchaseService } from './bulkPurchase'
export { NotificationService } from './notification' export { NotificationService } from './notification'
export { AuditService } from './audit'
export { WecomService } from './wecom' export { WecomService } from './wecom'
// TODO: 按需添加其他业务模块 // TODO: 按需添加其他业务模块

View File

@@ -0,0 +1,162 @@
<template>
<div ref="chartRef" class="distribution-chart" role="img" :aria-label="ariaLabel"></div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
import { useSettingStore } from '@/store/modules/setting'
import { getCssVar } from '@/utils/ui'
export interface AuditDistributionItem {
code: string
name: string
count: number
}
const props = withDefaults(
defineProps<{
data: AuditDistributionItem[]
type?: 'donut' | 'bar'
title: string
}>(),
{ type: 'donut' }
)
const emit = defineEmits<{ select: [code: string] }>()
const settingStore = useSettingStore()
const chartRef = ref<HTMLElement>()
const ariaLabel = computed(() =>
props.data.length
? `${props.title}${props.data.map((item) => `${item.name} ${item.count}`).join('')}`
: `${props.title}:暂无数据`
)
let chart: echarts.ECharts | undefined
let resizeObserver: ResizeObserver | undefined
const colors = () => [
getCssVar('--el-color-primary'),
getCssVar('--el-color-success'),
getCssVar('--el-color-warning'),
getCssVar('--el-color-danger'),
getCssVar('--el-color-info'),
getCssVar('--el-color-primary-light-3')
]
const textColor = () => getCssVar('--el-text-color-regular') || '#606266'
const splitColor = () => getCssVar('--el-border-color-lighter') || '#ebeef5'
const options = (): EChartsOption => {
if (props.type === 'bar') {
return {
color: colors(),
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { top: 8, right: 72, bottom: 8, left: 8, containLabel: true },
xAxis: {
type: 'value',
minInterval: 1,
splitNumber: 4,
axisLabel: {
color: textColor(),
hideOverlap: true,
formatter: (value: number) => {
const absolute = Math.abs(value)
if (absolute >= 10000) {
const formatted = value / 10000
return `${Number.isInteger(formatted) ? formatted : formatted.toFixed(1)}`
}
if (absolute >= 1000) {
const formatted = value / 1000
return `${Number.isInteger(formatted) ? formatted : formatted.toFixed(1)}k`
}
return String(value)
}
},
splitLine: { lineStyle: { color: splitColor(), type: 'dashed' } }
},
yAxis: {
type: 'category',
data: props.data.map((item) => item.name),
inverse: true,
axisLabel: { color: textColor(), overflow: 'truncate', width: 90 },
axisTick: { show: false },
axisLine: { show: false }
},
series: [
{
type: 'bar',
data: props.data.map((item) => ({
value: item.count,
code: item.code,
itemStyle: {
color: getCssVar('--el-color-primary'),
borderRadius: [0, 4, 4, 0]
}
})),
barMaxWidth: 24,
label: { show: true, position: 'right', color: textColor() }
}
]
}
}
return {
color: colors(),
tooltip: { trigger: 'item', formatter: '{b}<br/>{c}{d}%' },
legend: {
type: 'scroll',
bottom: 0,
left: 'center',
textStyle: { color: textColor() }
},
series: [
{
type: 'pie',
radius: ['42%', '68%'],
center: ['50%', '43%'],
avoidLabelOverlap: true,
itemStyle: { borderColor: getCssVar('--el-bg-color'), borderWidth: 2 },
label: { show: false },
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
data: props.data.map((item) => ({
value: item.count,
name: item.name,
code: item.code
}))
}
]
}
}
const render = async () => {
await nextTick()
if (!chartRef.value) return
if (!chart) {
chart = echarts.init(chartRef.value)
chart.on('click', (params) => {
const code = (params.data as { code?: string } | undefined)?.code
if (code) emit('select', code)
})
}
chart.setOption(options(), true)
}
watch(() => props.data, render, { deep: true })
watch(() => settingStore.isDark, render)
onMounted(() => {
render()
if (chartRef.value) {
resizeObserver = new ResizeObserver(() => chart?.resize())
resizeObserver.observe(chartRef.value)
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
chart?.dispose()
chart = undefined
})
</script>
<style scoped>
.distribution-chart {
width: 100%;
height: 260px;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<ArtTable
:data="items"
:loading="Boolean(loading)"
:pagination="false"
row-key="event_id"
stripe
:margin-top="0"
>
<template #default>
<ElTableColumn label="发生时间" prop="occurred_at" width="180">
<template #default="{ row }">{{ formatDateTime(row.occurred_at) }}</template>
</ElTableColumn>
<ElTableColumn label="动作" min-width="180">
<template #default="{ row }">{{ row.action_name || '-' }}</template>
</ElTableColumn>
<ElTableColumn label="摘要" prop="summary" min-width="240" show-overflow-tooltip />
<ElTableColumn label="操作者" min-width="150">
<template #default="{ row }">{{ row.actor_name || row.actor_id || '-' }}</template>
</ElTableColumn>
<ElTableColumn label="结果" width="100" align="center">
<template #default="{ row }"
><ElTag :type="resultMeta(row).type">{{ resultMeta(row).label }}</ElTag></template
>
</ElTableColumn>
<ElTableColumn label="风险" width="90" align="center">
<template #default="{ row }"
><ElTag effect="plain" :type="riskMeta(row).type">{{
riskMeta(row).label
}}</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>
<script setup lang="ts">
import type { AuditEventView } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { auditResultMeta, auditRiskMeta } from '@/utils/business/audit'
defineProps<{ items: AuditEventView[]; loading?: boolean }>()
defineEmits<{ detail: [row: AuditEventView] }>()
const resultMeta = (row: AuditEventView) =>
auditResultMeta[row.result] || { label: row.result, type: 'info' as const }
const riskMeta = (row: AuditEventView) =>
auditRiskMeta[row.risk_level] || { label: row.risk_level, type: 'info' as const }
</script>

View File

@@ -0,0 +1,462 @@
<template>
<ElDrawer
v-model="visible"
:title="title"
size="min(760px, 92vw)"
destroy-on-close
append-to-body
lock-scroll
modal
modal-class="audit-investigation-overlay"
>
<div v-loading="loading" class="investigation-drawer">
<ElTimeline v-if="eventItems.length">
<ElTimelineItem
v-for="item in eventItems"
:key="item.event_id"
:timestamp="formatDateTime(item.occurred_at)"
placement="top"
:type="auditResultMeta[item.result]?.type"
>
<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>
</div>
<p>{{ item.summary || '-' }}</p>
<div class="node-meta">
<span>{{ item.actor_name || item.actor_id || '-' }}</span>
<span>{{ auditSourceLabels[item.source] || item.source }}</span>
</div>
</article>
</ElTimelineItem>
</ElTimeline>
<ElTimeline v-else-if="linkNodes.length">
<ElTimelineItem
v-for="node in linkNodes"
:key="node.node_id"
:timestamp="formatDateTime(node.occurred_at)"
placement="top"
:type="node.result === 'failed' ? 'danger' : node.reference_only ? 'info' : 'primary'"
>
<article class="timeline-node">
<div class="node-head">
<strong>{{ node.title }}</strong>
<ElTag size="small" effect="plain">{{
auditResultDisplay(node.result, node.result_name)
}}</ElTag>
</div>
<p>{{ node.summary || '-' }}</p>
<div 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>
</ElTimeline>
<ElTimeline v-else-if="financeItems.length">
<ElTimelineItem
v-for="item in financeItems"
:key="item.node_id"
:timestamp="formatDateTime(item.occurred_at)"
placement="top"
:type="item.result === 'failed' ? 'danger' : 'primary'"
>
<article class="timeline-node">
<div class="node-head">
<strong>{{ item.title }}</strong>
<ElTag size="small" effect="plain">
{{ 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>
</article>
</ElTimelineItem>
</ElTimeline>
<ElTimeline v-else-if="subjectItems.length">
<ElTimelineItem
v-for="(item, index) in subjectItems"
:key="`${item.occurred_at}-${item.action_code}-${index}`"
:timestamp="formatDateTime(item.occurred_at)"
placement="top"
:type="auditResultMeta[item.result]?.type"
>
<article class="timeline-node">
<div class="node-head">
<strong>{{ item.action_name || item.action_code }}</strong>
<ElTag size="small" :type="auditResultMeta[item.result]?.type">
{{ auditResultMeta[item.result]?.label || item.result }}
</ElTag>
</div>
<p>{{ item.subject_summary || '-' }}</p>
</article>
</ElTimelineItem>
</ElTimeline>
<template v-else-if="eventDetail">
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="动作">{{ eventDetail.action_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="发生时间">
{{ formatDateTime(eventDetail.occurred_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="操作者">
{{ eventDetail.actor_name || eventDetail.actor_id || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="来源">
{{ auditSourceLabels[eventDetail.source] || eventDetail.source }}
</ElDescriptionsItem>
<ElDescriptionsItem label="结果">
<ElTag :type="auditResultMeta[eventDetail.result]?.type">
{{ auditResultMeta[eventDetail.result]?.label || eventDetail.result }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="风险">
<ElTag :type="auditRiskMeta[eventDetail.risk_level]?.type">
{{ auditRiskMeta[eventDetail.risk_level]?.label || eventDetail.risk_level }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="摘要" :span="2">
{{ eventDetail.summary || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="eventDetail.error_summary" label="错误摘要" :span="2">
{{ eventDetail.error_summary }}
</ElDescriptionsItem>
</ElDescriptions>
</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="结果">
<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"
>
<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>
</section>
</template>
<ElEmpty v-else-if="!loading" description="当前在线窗口内没有记录" />
<div v-if="total > pageSize" class="pagination">
<ElPagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="load"
/>
</div>
</div>
</ElDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { AuditService } from '@/api/modules'
import type {
AuditEventDetail,
AuditEventView,
AuditFinanceTimelineNode,
AuditLinkTimelineNode,
AuditSubjectActivity,
IntegrationDetailResponse
} from '@/types/api'
import {
auditJson,
auditResultDisplay,
auditResultMeta,
auditRiskMeta,
auditSourceLabels,
integrationCategoryMeta
} from '@/utils/business/audit'
import { formatDateTime } from '@/utils/business/format'
import type { AuditInvestigationTarget } from './types'
const props = defineProps<{
modelValue: boolean
target?: AuditInvestigationTarget | null
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const titleMap: Record<AuditInvestigationTarget['mode'], string> = {
event: '审计事件详情',
actor: '操作者行为时间线',
resource: '资源审计时间线',
request: '请求链路',
correlation: '业务关联链路',
integration: '外部交互详情',
finance: '财务审计时间线',
agent: '资源审计时间线',
enterprise: '资源审计时间线'
}
const title = computed(() => (props.target ? titleMap[props.target.mode] : '审计调查'))
const loading = ref(false)
const page = ref(1)
const pageSize = 20
const total = ref(0)
const eventItems = ref<AuditEventView[]>([])
const linkNodes = ref<AuditLinkTimelineNode[]>([])
const financeItems = ref<AuditFinanceTimelineNode[]>([])
const subjectItems = ref<AuditSubjectActivity[]>([])
const eventDetail = ref<AuditEventDetail>()
const integrationDetail = ref<IntegrationDetailResponse>()
let loadSequence = 0
const recordSourceLabels: Record<string, string> = {
audit_event: '审计事件',
integration_log: '外部交互',
outbox_event: '可靠投递',
asynq_task: '异步任务引用',
domain_ledger_ref: '业务台账引用'
}
const clear = () => {
eventItems.value = []
linkNodes.value = []
financeItems.value = []
subjectItems.value = []
eventDetail.value = undefined
integrationDetail.value = undefined
total.value = 0
}
const hasJsonContent = (value?: Record<string, unknown> | null) =>
Boolean(value && Object.keys(value).length)
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
const sequence = ++loadSequence
clear()
loading.value = true
try {
if (props.target.mode === 'event') {
const data = (await AuditService.getEventDetail(props.target.id)).data
if (sequence === loadSequence) eventDetail.value = data
} else if (props.target.mode === 'actor') {
const data = (
await AuditService.getActorEvents(props.target.actorKind, props.target.id, {
page: page.value,
page_size: pageSize
})
).data
if (sequence === loadSequence) {
eventItems.value = data.items || []
total.value = data.total
}
} else if (props.target.mode === 'resource') {
const data = (
await AuditService.getResourceTimeline(props.target.resourceType, props.target.id, {
page: page.value,
page_size: pageSize
})
).data
if (sequence === loadSequence) {
eventItems.value = data.items || []
total.value = data.total
}
} else if (props.target.mode === 'request') {
const nodes = (await AuditService.getRequestTimeline(props.target.id)).data.nodes || []
if (sequence === loadSequence) linkNodes.value = nodes
} else if (props.target.mode === 'correlation') {
const nodes = (await AuditService.getCorrelationTimeline(props.target.id)).data.nodes || []
if (sequence === loadSequence) linkNodes.value = nodes
} else if (props.target.mode === 'finance') {
const data = (
await AuditService.getFinanceTimeline({
[props.target.field]: props.target.value,
page: page.value,
page_size: pageSize
})
).data
if (sequence === loadSequence) {
financeItems.value = data.items || []
total.value = data.total
}
} else if (props.target.mode === 'agent') {
const data = (
await AuditService.getAgentResourceActivities(
props.target.resourceType,
props.target.id,
{
page: page.value,
page_size: pageSize
}
)
).data
if (sequence === loadSequence) {
subjectItems.value = data.items || []
total.value = data.total
}
} else if (props.target.mode === 'enterprise') {
const data = (
await AuditService.getEnterpriseResourceActivities(
props.target.resourceType as 'iot_card' | 'device',
props.target.id,
{ page: page.value, page_size: pageSize }
)
).data
if (sequence === loadSequence) {
subjectItems.value = data.items || []
total.value = data.total
}
} else {
const data = (await AuditService.getIntegrationDetail(props.target.id)).data
if (sequence === loadSequence) integrationDetail.value = data
}
} catch {
if (sequence === loadSequence) {
ElMessage.error(
props.target.mode === 'agent' || props.target.mode === 'enterprise'
? '活动不可用'
: '审计调查数据加载失败'
)
}
} finally {
if (sequence === loadSequence) loading.value = false
}
}
watch(
() => [props.modelValue, props.target] as const,
([open]) => {
if (!open || !props.target) {
loadSequence++
clear()
loading.value = false
return
}
page.value = 1
load()
},
{ deep: true }
)
</script>
<style scoped>
.investigation-drawer {
min-height: 240px;
}
.timeline-node {
padding: 14px 16px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
}
.timeline-node p {
margin: 10px 0;
}
.node-head,
.node-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
justify-content: space-between;
}
.node-meta {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.json-section {
margin-top: 20px;
}
.json-section h4 {
margin: 0 0 8px;
}
pre {
max-height: 360px;
padding: 12px;
margin: 0;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
background: var(--el-fill-color-light);
border-radius: 6px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
<style>
.audit-investigation-overlay .el-drawer__body {
overscroll-behavior: contain;
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<AuditInvestigationDrawer
v-model="auditInvestigationVisible"
:target="auditInvestigationTarget"
/>
<AuditResourceSearchDialog
v-model="auditResourceSearchVisible"
:resource-type="auditResourceSearchType"
:initial-keyword="auditResourceSearchKeyword"
/>
</template>
<script setup lang="ts">
import AuditInvestigationDrawer from './AuditInvestigationDrawer.vue'
import AuditResourceSearchDialog from './AuditResourceSearchDialog.vue'
import {
auditInvestigationTarget,
auditInvestigationVisible,
auditResourceSearchKeyword,
auditResourceSearchType,
auditResourceSearchVisible
} from './investigationController'
</script>

View File

@@ -0,0 +1,154 @@
<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-for="resource in stableResourceRefs"
:key="`${resource.resource_type}:${resource.resource_id}`"
link
type="primary"
@click="openResource(resource)"
>
资源审计时间线
</ElButton>
<ElButton
v-for="resource in searchableResourceRefs"
:key="`${resource.resource_type}:${resource.resource_key}`"
link
type="primary"
@click="searchResource(resource)"
>
精确注册资源
</ElButton>
<ElButton
v-if="refs?.request_id"
link
type="primary"
@click="openTimeline('request', refs.request_id)"
>
请求链路
</ElButton>
<ElButton
v-if="refs?.correlation_id"
link
type="primary"
@click="openTimeline('correlation', refs.correlation_id)"
>
业务关联链路
</ElButton>
<ElButton
v-for="item in stableIntegrationRefs"
:key="item.integration_id"
link
type="primary"
@click="openIntegration(item.integration_id)"
>
外部交互
</ElButton>
</div>
<span v-else-if="showEmpty" class="muted">无可靠调查引用</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type {
AuditInvestigationRefs,
AuditInvestigationResourceRef,
AuditSearchResourceType
} from '@/types/api'
import { openAuditInvestigation, openAuditResourceSearch } from './investigationController'
const props = withDefaults(
defineProps<{
refs?: AuditInvestigationRefs | null
currentEventId?: string
showEmpty?: boolean
}>(),
{ currentEventId: '', showEmpty: true }
)
const searchableTypes = new Set<AuditSearchResourceType>([
'iot_card',
'device',
'shop',
'order',
'refund'
])
const eventId = computed(() => {
const id = props.refs?.event_id?.trim()
return id && id !== props.currentEventId ? id : ''
})
const actorRef = computed(() => {
const actor = props.refs?.actor_ref
return actor?.kind && actor.id ? actor : null
})
const stableResourceRefs = computed(() =>
(props.refs?.resource_refs || []).filter(
(resource): resource is AuditInvestigationResourceRef & { resource_id: string } =>
Boolean(resource.resource_type && resource.resource_id)
)
)
const searchableResourceRefs = computed(() =>
(props.refs?.resource_refs || []).filter(
(
resource
): resource is AuditInvestigationResourceRef & {
resource_type: AuditSearchResourceType
resource_key: string
} =>
!resource.resource_id &&
Boolean(resource.resource_key) &&
searchableTypes.has(resource.resource_type as AuditSearchResourceType)
)
)
const stableIntegrationRefs = computed(() =>
(props.refs?.integration_refs || []).filter((item) => Boolean(item.integration_id))
)
const hasLinks = computed(() =>
Boolean(
eventId.value ||
actorRef.value ||
props.refs?.request_id ||
props.refs?.correlation_id ||
stableResourceRefs.value.length ||
searchableResourceRefs.value.length ||
stableIntegrationRefs.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 })
const openActor = () => {
const actor = actorRef.value
if (actor) {
openAuditInvestigation({ mode: 'actor', actorKind: actor.kind, id: actor.id })
}
}
const openResource = (resource: AuditInvestigationResourceRef & { resource_id: string }) =>
openAuditInvestigation({
mode: 'resource',
resourceType: resource.resource_type,
id: resource.resource_id
})
const searchResource = (
resource: AuditInvestigationResourceRef & {
resource_type: AuditSearchResourceType
resource_key: string
}
) => openAuditResourceSearch(resource.resource_type, resource.resource_key)
</script>
<style scoped>
.investigation-links {
display: flex;
flex-wrap: wrap;
gap: 2px 8px;
}
.muted {
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<ElDialog
v-model="visible"
title="精确注册资源"
width="min(880px, 92vw)"
append-to-body
destroy-on-close
lock-scroll
>
<div v-loading="loading" class="result-list">
<ElCard
v-for="row in items"
:key="`${row.resource_type}:${row.resource_id || row.resource_key}`"
shadow="never"
class="resource-card"
>
<template #header>
<div class="card-header">
<strong>业务信息</strong>
<ElButton link type="primary" :disabled="!row.resource_id" @click="openTimeline(row)">
<template #icon>
<ElIcon><Clock /></ElIcon>
</template>
审计时间线
</ElButton>
</div>
</template>
<div v-if="snapshotDetails(row).length" class="snapshot-grid">
<div v-for="item in snapshotDetails(row)" :key="item.label" class="snapshot-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<div v-else class="snapshot-item">
<span>业务标识</span>
<strong>{{ row.resource_key || '-' }}</strong>
</div>
</ElCard>
<ElEmpty v-if="!loading && !items.length" description="未找到匹配的注册资源" />
</div>
<div v-if="total > pageSize" class="pagination">
<ElPagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="search(false)"
/>
</div>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Clock } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { AuditService } from '@/api/modules'
import type { AuditResourceCandidate, AuditSearchResourceType } from '@/types/api'
import { openAuditInvestigation } from './investigationController'
const props = defineProps<{
modelValue: boolean
resourceType: AuditSearchResourceType
initialKeyword?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const keyword = ref('')
const loading = ref(false)
const page = ref(1)
const pageSize = 10
const total = ref(0)
const items = ref<AuditResourceCandidate[]>([])
let searchSequence = 0
const resourceTypeLabels: Record<AuditSearchResourceType, string> = {
iot_card: 'IoT卡',
device: '设备',
shop: '店铺',
order: '订单',
refund: '退款单'
}
const resourceTypeLabel = computed(() => resourceTypeLabels[props.resourceType])
interface SnapshotField {
label: string
keys: string[]
format?: (value: unknown) => string
}
const textValue = (value: unknown) => String(value ?? '-')
const refundStatusValue = (value: unknown) =>
({ 1: '待审批', 2: '已通过', 3: '已拒绝', 4: '已退回' })[Number(value)] || textValue(value)
const centAmountValue = (value: unknown) => {
const amount = Number(value)
return Number.isFinite(amount) ? `¥${(amount / 100).toFixed(2)}` : textValue(value)
}
const snapshotFields: Record<AuditSearchResourceType, SnapshotField[]> = {
iot_card: [
{ label: 'ICCID', keys: ['iccid', 'iccid_19', 'iccid_20'] },
{ label: '虚拟号', keys: ['virtual_no'] },
{ label: 'MSISDN', keys: ['msisdn'] },
{ label: '运营商', keys: ['carrier_name', 'carrier_type'] }
],
device: [
{ label: '虚拟号', keys: ['virtual_no'] },
{ label: 'IMEI', keys: ['imei'] },
{ label: 'SN', keys: ['sn', 'serial_no'] },
{ label: '设备编号', keys: ['device_no'] }
],
shop: [
{ label: '店铺编号', keys: ['shop_code'] },
{ label: '店铺名称', keys: ['shop_name', 'name'] },
{ label: '联系人', keys: ['contact_name'] },
{ label: '联系电话', keys: ['contact_phone', 'phone'] }
],
order: [
{ label: '订单号', keys: ['order_no'] },
{ label: '资产编号', keys: ['asset_identifier'] },
{ label: '订单状态', keys: ['status_name'] },
{ label: '订单金额', keys: ['total_amount', 'order_amount'], format: centAmountValue }
],
refund: [
{ label: '退款单号', keys: ['refund_no'] },
{ label: '订单号', keys: ['order_no'] },
{ label: '资产编号', keys: ['asset_identifier'] },
{
label: '申请退款金额',
keys: ['requested_refund_amount'],
format: centAmountValue
},
{ label: '状态', keys: ['status_name', 'status'], format: refundStatusValue }
]
}
const snapshotDetails = (row: AuditResourceCandidate) => {
const snapshot = row.identity_snapshot || {}
return snapshotFields[row.resource_type].flatMap((field) => {
const key = field.keys.find((candidate) => {
const value = snapshot[candidate]
return value !== undefined && value !== null && value !== ''
})
if (!key) return []
const value = snapshot[key]
return [{ label: field.label, value: field.format?.(value) || textValue(value) }]
})
}
const search = async (resetPage: boolean) => {
const exactKeyword = keyword.value.trim()
if (!exactKeyword) {
ElMessage.warning(`当前记录缺少可用于搜索${resourceTypeLabel.value}的业务标识`)
return
}
if (resetPage) page.value = 1
const sequence = ++searchSequence
loading.value = true
try {
const data = (
await AuditService.searchResources({
resource_type: props.resourceType,
keyword: exactKeyword,
page: page.value,
page_size: pageSize
})
).data
if (sequence === searchSequence) {
items.value = data.items || []
total.value = data.total
}
} catch {
if (sequence === searchSequence) ElMessage.error('注册资源搜索失败')
} finally {
if (sequence === searchSequence) loading.value = false
}
}
const openTimeline = (row: AuditResourceCandidate) => {
if (!row.resource_id) return
visible.value = false
openAuditInvestigation({
mode: 'resource',
resourceType: row.resource_type,
id: row.resource_id
})
}
watch(
() => props.modelValue,
(open) => {
if (!open) {
searchSequence++
loading.value = false
items.value = []
total.value = 0
return
}
keyword.value = props.initialKeyword?.trim() || ''
page.value = 1
items.value = []
total.value = 0
if (keyword.value) void search(true)
}
)
</script>
<style scoped lang="scss">
.result-list {
min-height: 180px;
}
.resource-card + .resource-card {
margin-top: 12px;
}
.card-header {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.snapshot-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.snapshot-item {
min-width: 0;
padding: 12px 14px;
background: var(--el-fill-color-light);
border-radius: 6px;
span,
strong {
display: block;
overflow-wrap: anywhere;
}
span {
margin-bottom: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
strong {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
}
@media (width <= 600px) {
.snapshot-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,25 @@
import { ref } from 'vue'
import type { AuditInvestigationTarget } from './types'
import type { AuditSearchResourceType } from '@/types/api'
export const auditInvestigationVisible = ref(false)
export const auditInvestigationTarget = ref<AuditInvestigationTarget>()
export const auditResourceSearchVisible = ref(false)
export const auditResourceSearchType = ref<AuditSearchResourceType>('iot_card')
export const auditResourceSearchKeyword = ref('')
export const openAuditInvestigation = (target: AuditInvestigationTarget) => {
auditResourceSearchVisible.value = false
auditInvestigationTarget.value = target
auditInvestigationVisible.value = true
}
export const openAuditResourceSearch = (
resourceType: AuditSearchResourceType,
keyword?: string | null
) => {
auditInvestigationVisible.value = false
auditResourceSearchType.value = resourceType
auditResourceSearchKeyword.value = keyword?.trim() || ''
auditResourceSearchVisible.value = true
}

View File

@@ -0,0 +1,24 @@
import type { AuditActorKind, AuditSubjectResourceType } from '@/types/api'
export type AuditFinanceField =
| 'shop_id'
| 'wallet_id'
| 'order_id'
| 'order_no'
| 'payment_id'
| 'payment_no'
| 'refund_id'
| 'refund_no'
| 'recharge_id'
| 'recharge_no'
| 'approval_instance_id'
| 'third_party_trade_no'
| 'correlation_id'
export type AuditInvestigationTarget =
| { mode: 'event'; id: string }
| { mode: 'actor'; id: string; actorKind: AuditActorKind }
| { mode: 'resource'; id: string; resourceType: string }
| { mode: 'agent' | 'enterprise'; id: string; resourceType: AuditSubjectResourceType }
| { mode: 'finance'; field: AuditFinanceField; value: string | number }
| { mode: 'request' | 'correlation' | 'integration'; id: string }

View File

@@ -0,0 +1,27 @@
/** 八月审计链路页面与按钮权限。 */
export const AUDIT_PERMISSIONS = {
centerPage: 'audit:center_view',
eventList: 'audit:event_list',
eventDetail: 'audit:event_detail',
actorTimeline: 'audit:actor_timeline',
resourceSearch: 'audit:resource_search',
resourceTimeline: 'audit:resource_timeline',
requestTimeline: 'audit:request_timeline',
correlationTimeline: 'audit:correlation_timeline',
financeTimeline: 'audit:finance_timeline',
riskPage: 'audit:risk_view',
riskEvents: 'audit:risk_events',
integrationPage: 'audit:integration_view',
integrationDetail: 'audit:integration_detail',
agentActivity: 'audit:agent_resource_activity',
enterpriseActivity: 'audit:enterprise_resource_activity',
cardEntry: 'audit:card_entry',
deviceEntry: 'audit:device_entry',
shopEntry: 'audit:shop_entry',
enterpriseEntry: 'audit:enterprise_entry',
orderEntry: 'audit:order_entry',
refundEntry: 'audit:refund_entry',
walletEntry: 'audit:wallet_entry'
} as const
export type AuditPermission = (typeof AUDIT_PERMISSIONS)[keyof typeof AUDIT_PERMISSIONS]

View File

@@ -2,6 +2,7 @@ import { RoutesAlias } from '../routesAlias'
import { AppRouteRecord } from '@/types/router' import { AppRouteRecord } from '@/types/router'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase' import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
import { JULY_PERMISSIONS } from '@/config/constants/julyIteration' import { JULY_PERMISSIONS } from '@/config/constants/julyIteration'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
/** /**
* 菜单列表、异步路由 * 菜单列表、异步路由
@@ -692,6 +693,72 @@ export const asyncRoutes: AppRouteRecord[] = [
] ]
}, },
// 审计中心
{
path: '/audit',
name: 'AuditCenter',
component: RoutesAlias.Home,
meta: {
title: '审计中心',
icon: '&#xe7c9;',
permissions: [AUDIT_PERMISSIONS.centerPage]
},
children: [
{
path: 'events',
name: 'AuditEvents',
component: RoutesAlias.AuditEvents,
meta: {
title: '审计事件',
permissions: [AUDIT_PERMISSIONS.eventList],
keepAlive: true
}
},
{
path: 'events/:eventId',
name: 'AuditEventDetail',
component: RoutesAlias.AuditEventDetail,
meta: {
title: '审计事件详情',
permissions: [AUDIT_PERMISSIONS.eventDetail],
isHide: true,
keepAlive: false
}
},
{
path: 'risks',
name: 'AuditRisks',
component: RoutesAlias.AuditRisks,
meta: {
title: '风险中心',
permissions: [AUDIT_PERMISSIONS.riskPage],
keepAlive: true
}
},
{
path: 'integrations',
name: 'AuditIntegrations',
component: RoutesAlias.AuditIntegrations,
meta: {
title: '外部交互',
permissions: [AUDIT_PERMISSIONS.integrationPage],
keepAlive: true
}
},
{
path: 'integrations/:integrationId',
name: 'AuditIntegrationDetail',
component: RoutesAlias.AuditIntegrationDetail,
meta: {
title: '外部交互详情',
permissions: [AUDIT_PERMISSIONS.integrationDetail],
isHide: true,
keepAlive: false
}
}
]
},
// 佣金管理 // 佣金管理
{ {
path: '/commission', path: '/commission',

View File

@@ -23,6 +23,13 @@ export enum RoutesAlias {
CarrierManagement = '/system/carrier-management', // 运营商管理 CarrierManagement = '/system/carrier-management', // 运营商管理
UserCenter = '/system/user-center', // 用户中心 UserCenter = '/system/user-center', // 用户中心
// 审计中心
AuditEvents = '/audit/events',
AuditEventDetail = '/audit/events/detail',
AuditRisks = '/audit/risks',
AuditIntegrations = '/audit/integrations',
AuditIntegrationDetail = '/audit/integrations/detail',
// 套餐管理 // 套餐管理
PackageList = '/package-management/package-list', // 套餐列表 PackageList = '/package-management/package-list', // 套餐列表
PackageDetail = '/package-management/package-list/detail', // 套餐列表详情 PackageDetail = '/package-management/package-list/detail', // 套餐列表详情

449
src/types/api/audit.ts Normal file
View File

@@ -0,0 +1,449 @@
import type { PaginationParams } from './common'
export type AuditActorKind =
| 'account'
| 'personal_customer'
| 'openapi'
| 'system_task'
| 'scheduled_job'
| 'external_system'
export type AuditResult = 'success' | 'failed' | 'denied' | 'partial' | 'unknown'
export type AuditRiskLevel = 'low' | 'normal' | 'high' | 'critical'
export type AuditCategory =
| 'configuration'
| 'reliability'
| 'asset'
| 'security'
| 'identity'
| 'business'
export type AuditSource =
| 'admin_api'
| 'personal_api'
| 'openapi'
| 'worker'
| 'scheduler'
| 'callback'
export type AuditScopeType = 'platform' | 'shop' | 'personal_customer'
export type IntegrationProvider =
| 'ctcc'
| 'cmcc'
| 'cucc'
| 'wechat_pay'
| 'alipay'
| 'fuiou'
| 'wecom'
| 'gateway'
export type IntegrationDirection = 'inbound' | 'outbound'
export type IntegrationResult =
| 'pending'
| 'success'
| 'failed'
| 'unknown'
| 'not_found'
| 'invalid_payload'
| 'conflict'
| 'ignored'
| 'merged'
| 'rate_limited'
| 'completed'
| 'cancelled'
export type IntegrationResultCategory =
| 'processing'
| 'succeeded'
| 'indeterminate'
| 'failed'
| 'not_sent'
export type AuditSubjectResourceType =
| 'iot_card'
| 'device'
| 'asset_allocation_record'
| 'exchange_order'
| 'shop'
| 'enterprise'
export interface AuditRetentionInfo {
online_from: string
archived_before: string
timezone: string
}
export interface AuditActorRef {
kind: AuditActorKind
id: string
}
export interface AuditInvestigationResourceRef {
resource_type: string
resource_id?: string | null
resource_key?: string | null
display_name?: string | null
}
export interface AuditInvestigationRefs {
event_id?: string | null
actor_ref?: AuditActorRef | null
resource_refs?: AuditInvestigationResourceRef[]
request_id?: string | null
correlation_id?: string | null
integration_refs?: Array<{ integration_id: string }>
}
export interface AuditResourceView extends AuditInvestigationResourceRef {
relation: 'primary' | 'affected' | 'reference'
role: string
display_name: string
subject_summary?: string | null
subject_visibility?: 'internal_only' | 'subject_result' | 'subject_detail'
identity_snapshot?: Record<string, unknown> | null
before_data?: Record<string, unknown> | null
after_data?: Record<string, unknown> | null
subject_data?: Record<string, unknown> | null
sort_order?: number | null
created_at?: string | null
}
export interface AuditEventView {
event_id: string
parent_event_id?: string | null
action_code: string
action_name: string
category: AuditCategory
summary: string
result: AuditResult
risk_level: AuditRiskLevel
source: AuditSource
actor_kind: AuditActorKind
actor_id: string
actor_name: string
actor_shop_id?: number | null
actor_shop_name?: string | null
actor_enterprise_id?: number | null
actor_enterprise_name?: string | null
scope_type: AuditScopeType
scope_id?: string | null
scope_name?: string | null
request_id?: string | null
correlation_id?: string | null
request_method?: string | null
request_path?: string | null
ip_address?: string | null
user_agent?: string | null
success_count?: number | null
fail_count?: number | null
batch_total?: number | null
error_code?: string | null
error_summary?: string | null
content_hash?: string | null
occurred_at: string
created_at: string
resources: AuditResourceView[]
investigation_refs?: AuditInvestigationRefs | null
metadata?: Record<string, unknown> | null
}
export interface AuditEventDetail extends AuditEventView {
retention: AuditRetentionInfo
}
export interface AuditPage<T> {
items: T[]
page: number
page_size: number
total: number
retention: AuditRetentionInfo
}
export type AuditEventPage = AuditPage<AuditEventView>
export interface AuditEventQuery extends PaginationParams {
created_from?: string
created_to?: string
action?: string
category?: AuditCategory
actor_kind?: AuditActorKind
actor_id?: string
source?: AuditSource
result?: AuditResult
risk?: AuditRiskLevel
scope_type?: AuditScopeType
scope_id?: string
resource_type?: string
resource_id?: string
resource_key?: string
request_id?: string
correlation_id?: string
}
export interface AuditActorEventQuery extends PaginationParams {
action?: string
result?: AuditResult
risk?: AuditRiskLevel
resource_type?: string
resource_id?: string
created_from?: string
created_to?: string
}
export interface AuditResourceTimelineQuery extends PaginationParams {
created_from?: string
created_to?: string
action?: string
result?: AuditResult
}
export type AuditSearchResourceType = 'iot_card' | 'device' | 'shop' | 'order' | 'refund'
export interface AuditResourceSearchQuery extends PaginationParams {
resource_type: AuditSearchResourceType
keyword: string
}
export interface AuditResourceCandidate extends AuditInvestigationResourceRef {
resource_type: AuditSearchResourceType
display_name: string
historical: boolean
identity_snapshot?: Record<string, unknown> | null
}
export type AuditResourceSearchPage = AuditPage<AuditResourceCandidate>
export interface AuditLinkTimelineNode {
node_id: string
record_source: 'audit_event' | 'integration_log' | 'outbox_event'
code: string
title: string
summary: string
result: string
result_name: string
occurred_at: string
request_id?: string | null
correlation_id?: string | null
parent_event_id?: string | null
reference_only?: boolean
resources?: AuditInvestigationResourceRef[]
investigation_refs?: AuditInvestigationRefs | null
fidelity?: Record<string, boolean | null> | null
}
export interface AuditLinkTimeline {
request_id?: string | null
correlation_id?: string | null
access_log_lookup_request_id?: string | null
nodes: AuditLinkTimelineNode[]
retention: AuditRetentionInfo
}
export interface AuditFinanceQuery extends PaginationParams {
shop_id?: number
wallet_id?: number
order_id?: number
order_no?: string
payment_id?: number
payment_no?: string
refund_id?: number
refund_no?: string
recharge_id?: number
recharge_no?: string
approval_instance_id?: number
third_party_trade_no?: string
actor_kind?: AuditActorKind
actor_id?: string
correlation_id?: string
created_from?: string
created_to?: string
}
export interface AuditFinanceTimelineNode {
node_id: string
record_source: string
code: string
title: string
result: string
result_name: string
occurred_at: string
shop_id?: number | null
amount?: number | null
balance_before?: number | null
balance_after?: number | null
currency?: string | null
wallet?: { resource_type: 'agent_wallet' | 'asset_wallet'; wallet_id: number } | null
amount_authority?: {
authoritative: boolean
table?: string
field?: string
conflict_rule?: string
} | null
facts?: Record<string, unknown> | null
investigation_refs?: AuditInvestigationRefs | null
}
export type AuditFinanceTimelinePage = AuditPage<AuditFinanceTimelineNode>
export interface AuditRiskQuery {
created_from?: string
created_to?: string
risk?: AuditRiskLevel
result?: AuditResult
action?: string
source?: AuditSource
}
export interface AuditRiskEventQuery extends AuditRiskQuery, PaginationParams {}
export interface AuditNamedCount {
code: string
name: string
count: number
}
export interface AuditRiskOverview {
total: number
bucket: 'hour' | 'day'
risks: AuditNamedCount[]
results: AuditNamedCount[]
actions: AuditNamedCount[]
sources: AuditNamedCount[]
signals: AuditNamedCount[]
trend: Array<Record<string, string | number>>
retention: AuditRetentionInfo
}
export type AuditRiskEventPage = AuditPage<AuditEventView>
export interface IntegrationQuery extends PaginationParams {
created_from?: string
created_to?: string
integration_id?: string
provider?: IntegrationProvider
direction?: IntegrationDirection
operation?: string
result?: IntegrationResult
result_category?: IntegrationResultCategory
external_id?: string
resource_type?: string
resource_id?: string
resource_key?: string
trigger_source?: string
trigger_scene?: string
trigger_series?: string
state_changed?: boolean
http_status?: number
provider_code?: string
request_id?: string
correlation_id?: string
}
export interface IntegrationResourceView {
type?: string | null
id?: string | null
key?: string | null
}
export interface IntegrationIdentityView {
integration_id: string
provider: IntegrationProvider
provider_name: string
direction: IntegrationDirection
direction_name: string
operation: string
operation_name: string
external_id?: string | null
}
export interface IntegrationResultView {
category: IntegrationResultCategory
code: IntegrationResult
name: string
duration_ms: number
http_status?: number | null
provider_code?: string | null
provider_message?: string | null
recovery_strategy?: string | null
state_changed: boolean
}
export interface IntegrationLinkageView {
audit_event_id?: number | null
request_id?: string | null
correlation_id?: string | null
}
export interface IntegrationTriggerView {
attempt: number
scene?: string | null
series?: string | null
source?: string | null
}
export interface IntegrationTimestampView {
created_at: string
scheduled_at?: string | null
started_at?: string | null
updated_at: string
}
export interface IntegrationContentView {
content_hash: string
metadata?: Record<string, unknown> | null
request_summary?: Record<string, unknown> | null
response_summary?: Record<string, unknown> | null
}
export interface IntegrationFidelityView {
attempt_sequence_reliable: boolean
correlation_available: boolean
provider_message_fidelity: string
resource_id_available: boolean
trigger_series_available: boolean
}
export interface IntegrationAttemptView {
attempt: number
created_at: string
duration_ms: number
integration_id: string
operation: string
operation_name: string
result: IntegrationResult
result_category: IntegrationResultCategory
result_name: string
sent: boolean
state_changed: boolean
}
export interface IntegrationListItem {
integration_id: string
provider: IntegrationProvider
provider_name: string
direction: IntegrationDirection
direction_name: string
operation: string
operation_name: string
result: IntegrationResult
result_name: string
result_category: IntegrationResultCategory
state_changed: boolean
duration_ms?: number | null
request_id?: string | null
correlation_id?: string | null
created_at: string
resource?: IntegrationResourceView | null
}
export type IntegrationListPage = AuditPage<IntegrationListItem>
export interface IntegrationOverview {
total: number
anomaly_count: number
stale_pending_count: number
state_changed_count: number
unknown_count: number
average_duration_ms: number
p95_duration_ms: number
providers: AuditNamedCount[]
directions: AuditNamedCount[]
results: Array<AuditNamedCount & { category: IntegrationResultCategory }>
trend: Array<Record<string, string | number>>
retention: AuditRetentionInfo
}
export interface IntegrationDetailResponse {
identity: IntegrationIdentityView
result: IntegrationResultView
resource?: IntegrationResourceView | null
linkage: IntegrationLinkageView
trigger: IntegrationTriggerView
timestamps: IntegrationTimestampView
content: IntegrationContentView
fidelity: IntegrationFidelityView
attempts: IntegrationAttemptView[]
retention: AuditRetentionInfo
}
export interface AuditSubjectActivityQuery extends PaginationParams {
created_from?: string
created_to?: string
}
export interface AuditSubjectResourceSummary {
resource_type?: string
identifier?: string
display_name?: string
[key: string]: unknown
}
export interface AuditSubjectActivity {
action_code: string
action_name: string
occurred_at: string
result: AuditResult
subject_summary: string
subject_data?: Record<string, unknown> | null
related_resources?: AuditSubjectResourceSummary[]
}
export interface AuditSubjectActivityPage extends AuditPage<AuditSubjectActivity> {
resource: AuditSubjectResourceSummary
}

View File

@@ -126,5 +126,8 @@ export * from './bulkPurchase'
// 站内通知相关 // 站内通知相关
export * from './notification' export * from './notification'
// 审计链路与调查中心
export * from './audit'
// 企业微信审批配置相关 // 企业微信审批配置相关
export * from './wecom' export * from './wecom'

View File

@@ -68,6 +68,12 @@ declare module 'vue' {
ArtWangEditor: typeof import('./../components/core/forms/ArtWangEditor.vue')['default'] ArtWangEditor: typeof import('./../components/core/forms/ArtWangEditor.vue')['default']
ArtWatermark: typeof import('./../components/core/others/ArtWatermark.vue')['default'] ArtWatermark: typeof import('./../components/core/others/ArtWatermark.vue')['default']
ArtWorkTab: typeof import('./../components/core/layouts/art-work-tab/index.vue')['default'] ArtWorkTab: typeof import('./../components/core/layouts/art-work-tab/index.vue')['default']
AuditDistributionChart: typeof import('./../components/business/audit/AuditDistributionChart.vue')['default']
AuditEventTable: typeof import('./../components/business/audit/AuditEventTable.vue')['default']
AuditInvestigationDrawer: typeof import('./../components/business/audit/AuditInvestigationDrawer.vue')['default']
AuditInvestigationHost: typeof import('./../components/business/audit/AuditInvestigationHost.vue')['default']
AuditInvestigationLinks: typeof import('./../components/business/audit/AuditInvestigationLinks.vue')['default']
AuditResourceSearchDialog: typeof import('./../components/business/audit/AuditResourceSearchDialog.vue')['default']
BasicSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/BasicSettings.vue')['default'] BasicSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/BasicSettings.vue')['default']
BatchOperationDialog: typeof import('./../components/business/BatchOperationDialog.vue')['default'] BatchOperationDialog: typeof import('./../components/business/BatchOperationDialog.vue')['default']
BatchRealnamePolicyDialog: typeof import('./../components/business/BatchRealnamePolicyDialog.vue')['default'] BatchRealnamePolicyDialog: typeof import('./../components/business/BatchRealnamePolicyDialog.vue')['default']

View File

@@ -34,6 +34,8 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
isFirstLevel?: boolean isFirstLevel?: boolean
/** 角色权限 */ /** 角色权限 */
roles?: string[] roles?: string[]
/** 页面访问权限,满足任意一个即可 */
permissions?: string[]
/** 导出任务固定场景 */ /** 导出任务固定场景 */
exportTaskScene?: ExportTaskScene exportTaskScene?: ExportTaskScene
/** 是否固定标签页 */ /** 是否固定标签页 */

137
src/utils/business/audit.ts Normal file
View File

@@ -0,0 +1,137 @@
import type {
AuditActorKind,
AuditCategory,
AuditResult,
AuditRiskLevel,
AuditScopeType,
AuditSource,
IntegrationResultCategory
} from '@/types/api'
export const auditCategoryLabels: Record<AuditCategory, string> = {
configuration: '配置',
reliability: '可靠性',
asset: '资产',
security: '安全',
identity: '身份',
business: '业务'
}
export const auditActorKindLabels: Record<AuditActorKind, string> = {
account: '人工账号',
personal_customer: '个人客户',
openapi: '开放接口账号',
system_task: '系统任务',
scheduled_job: '计划任务',
external_system: '外部系统'
}
export const auditScopeTypeLabels: Record<AuditScopeType, string> = {
platform: '平台',
shop: '店铺',
personal_customer: '个人客户'
}
export const auditSourceLabels: Record<AuditSource, string> = {
admin_api: '后台管理 API',
personal_api: '个人客户 API',
openapi: '代理 OpenAPI',
worker: '异步 Worker',
scheduler: '计划任务',
callback: '外部系统回调'
}
export const auditResourceRelationLabels: Record<string, string> = {
primary: '主要资源',
affected: '受影响资源',
reference: '引用资源'
}
export const auditSubjectVisibilityLabels: Record<string, string> = {
internal_only: '仅平台可见',
subject_result: '主体可见结论',
subject_detail: '主体可见安全详情'
}
export const auditResourceTypeLabels: Record<string, string> = {
iot_card: 'IoT 卡',
device: '设备',
asset_allocation_record: '资产分配记录',
exchange_order: '换卡订单',
shop: '店铺',
enterprise: '企业',
order: '订单',
refund: '退款',
integration_log: '外部集成记录'
}
export const auditResultMeta: Record<
AuditResult,
{ label: string; type: 'success' | 'danger' | 'warning' | 'info' }
> = {
success: { label: '成功', type: 'success' },
failed: { label: '失败', type: 'danger' },
denied: { label: '拒绝', type: 'danger' },
partial: { label: '部分成功', type: 'warning' },
unknown: { label: '未知', type: 'info' }
}
const auditResultLabels: Record<string, string> = {
success: '成功',
failed: '失败',
denied: '拒绝',
partial: '部分成功',
unknown: '未知',
pending: '处理中',
not_found: '未找到',
invalid_payload: '无效载荷',
conflict: '冲突',
ignored: '已忽略',
merged: '已合并',
rate_limited: '已限流',
completed: '已完成',
cancelled: '已取消'
}
export function auditResultDisplay(result?: string | null, resultName?: string | null): string {
const name = resultName?.trim()
if (name) return name
return result ? auditResultLabels[result] || result : '-'
}
export const auditRiskMeta: Record<
AuditRiskLevel,
{ label: string; type: 'success' | 'danger' | 'warning' | 'info' }
> = {
low: { label: '低', type: 'success' },
normal: { label: '普通', type: 'info' },
high: { label: '高', type: 'warning' },
critical: { label: '严重', type: 'danger' }
}
export const integrationCategoryMeta: Record<
IntegrationResultCategory,
{ label: string; type: 'success' | 'danger' | 'warning' | 'info' }
> = {
processing: { label: '处理中', type: 'warning' },
succeeded: { label: '成功', type: 'success' },
indeterminate: { label: '结果不确定', type: 'warning' },
failed: { label: '失败', type: 'danger' },
not_sent: { label: '未发送', type: 'info' }
}
export function toRfc3339(value?: string | Date | null): string | undefined {
if (!value) return undefined
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
export function fenDisplay(value?: number | null): string {
return typeof value === 'number' ? `¥${(value / 100).toFixed(2)}` : '-'
}
export function auditJson(value: unknown): string {
if (value === null || value === undefined) return '-'
if (typeof value === 'string') return value
return JSON.stringify(value, null, 2)
}

View File

@@ -0,0 +1,69 @@
import type { AuditSubjectResourceType } from '@/types/api'
import type { AuditFinanceField, AuditInvestigationTarget } from '@/components/business/audit/types'
interface AuditResourceRouteOptions {
userType?: number
resourceType: AuditSubjectResourceType | string
internalId?: string | number | null
businessIdentifier?: string | null
}
/** 根据当前主体选择平台内部 ID 或主体安全业务标识。 */
export function resolveAuditResourceTarget(
options: AuditResourceRouteOptions
): AuditInvestigationTarget | null {
const { userType, resourceType, internalId, businessIdentifier } = options
const normalizedBusinessIdentifier = businessIdentifier?.trim()
if (userType === 3) {
if (
!normalizedBusinessIdentifier ||
![
'iot_card',
'device',
'asset_allocation_record',
'exchange_order',
'shop',
'enterprise'
].includes(resourceType)
)
return null
return {
mode: 'agent',
resourceType: resourceType as AuditSubjectResourceType,
id: normalizedBusinessIdentifier
}
}
if (userType === 4) {
if (!normalizedBusinessIdentifier || !['iot_card', 'device'].includes(resourceType)) return null
return {
mode: 'enterprise',
resourceType: resourceType as AuditSubjectResourceType,
id: normalizedBusinessIdentifier
}
}
if (
internalId === undefined ||
internalId === null ||
internalId === '' ||
(typeof internalId === 'string' && !internalId.trim()) ||
(typeof internalId === 'number' && (!Number.isFinite(internalId) || internalId <= 0))
)
return null
return { mode: 'resource', resourceType, id: String(internalId).trim() }
}
export function resolveFinanceAuditTarget(
field: AuditFinanceField,
value?: string | number | null
): AuditInvestigationTarget | null {
const normalizedValue = typeof value === 'string' ? value.trim() : value
if (
normalizedValue === undefined ||
normalizedValue === null ||
normalizedValue === '' ||
(typeof normalizedValue === 'number' &&
(!Number.isFinite(normalizedValue) || normalizedValue <= 0))
)
return null
return { mode: 'finance', field, value: normalizedValue }
}

View File

@@ -9,8 +9,16 @@ const resolveTargetRoute = (
target: NotificationTarget target: NotificationTarget
): TargetRoute | null => { ): TargetRoute | null => {
const targetId = target.target_id === null ? '' : String(target.target_id) const targetId = target.target_id === null ? '' : String(target.target_id)
const targetType = target.target_type || refType
switch (refType) { if (targetType === 'integration_log') {
const integrationId = target.target_key?.trim()
return integrationId
? { path: `/audit/integrations/${encodeURIComponent(integrationId)}` }
: null
}
switch (targetType) {
case 'system_config': case 'system_config':
return { path: RoutesAlias.SystemConfigs } return { path: RoutesAlias.SystemConfigs }
case 'refund': case 'refund':
@@ -27,7 +35,6 @@ const resolveTargetRoute = (
return { path: RoutesAlias.ExpiringAssets } return { path: RoutesAlias.ExpiringAssets }
case 'shop_fund': case 'shop_fund':
return { path: RoutesAlias.AgentFundOverview } return { path: RoutesAlias.AgentFundOverview }
case 'integration_log':
case 'package': case 'package':
case 'asset': case 'asset':
case 'wecom_approval': case 'wecom_approval':

View File

@@ -335,6 +335,9 @@
JULY_PERMISSIONS, JULY_PERMISSIONS,
STATUS_SELECT_OPTIONS STATUS_SELECT_OPTIONS
} from '@/config/constants' } from '@/config/constants'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
defineOptions({ name: 'Account' }) // 定义组件名称,用于 KeepAlive 缓存控制 defineOptions({ name: 'Account' }) // 定义组件名称,用于 KeepAlive 缓存控制
@@ -670,6 +673,19 @@
const getActions = (row: any) => { const getActions = (row: any) => {
const actions: any[] = [] const actions: any[] = []
if (hasAuth(AUDIT_PERMISSIONS.resourceTimeline)) {
const auditTarget = resolveAuditResourceTarget({
resourceType: 'account',
internalId: row.id
})
if (auditTarget)
actions.push({
label: '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
}
if (hasAuth('account:patch_role')) { if (hasAuth('account:patch_role')) {
actions.push({ actions.push({
label: '分配角色', label: '分配角色',

View File

@@ -204,6 +204,9 @@
import { h, onActivated } from 'vue' import { h, onActivated } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import { EnterpriseService, ShopService } from '@/api/modules' import { EnterpriseService, ShopService } from '@/api/modules'
import { ElMessage, ElSwitch, ElCascader } from 'element-plus' import { ElMessage, ElSwitch, ElCascader } from 'element-plus'
import type { CascaderOption, CascaderValue, FormInstance, FormRules } from 'element-plus' import type { CascaderOption, CascaderValue, FormInstance, FormRules } from 'element-plus'
@@ -502,6 +505,21 @@
const getActions = (row: EnterpriseItem) => { const getActions = (row: EnterpriseItem) => {
const actions: any[] = [] const actions: any[] = []
if (hasAuth(AUDIT_PERMISSIONS.enterpriseEntry)) {
const auditTarget = resolveAuditResourceTarget({
userType: userStore.getUserInfo.user_type,
resourceType: 'enterprise',
internalId: row.id,
businessIdentifier: row.enterprise_code
})
if (auditTarget)
actions.push({
label: '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
}
if (hasAuth('enterprise_customer:look_customer')) { if (hasAuth('enterprise_customer:look_customer')) {
actions.push({ actions.push({
label: '账号列表', label: '账号列表',

View File

@@ -9,6 +9,9 @@
</ElTag> </ElTag>
</div> </div>
<div class="card-header-right"> <div class="card-header-right">
<ElButton v-if="auditTarget" type="primary" link @click="openAssetAudit">
{{ isSubjectActivityView ? '活动记录' : '审计记录' }}
</ElButton>
<ElButton <ElButton
v-if=" v-if="
cardInfo?.asset_type === 'card' && cardInfo?.asset_type === 'card' &&
@@ -311,7 +314,7 @@
{{ scope.row.gateway_extend || '-' }} {{ scope.row.gateway_extend || '-' }}
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn label="操作" width="180" fixed="right"> <ElTableColumn label="操作" width="280" fixed="right">
<template #default="scope"> <template #default="scope">
<!-- 根据网络状态显示启用或停用按钮 --> <!-- 根据网络状态显示启用或停用按钮 -->
<ElButton <ElButton
@@ -345,6 +348,24 @@
> >
更新实名状态 更新实名状态
</ElButton> </ElButton>
<ElButton
v-if="getBindingCardAuditTarget(scope.row)"
type="primary"
size="small"
link
@click="openBindingCardAudit(scope.row)"
>
卡审计
</ElButton>
<ElButton
v-if="getBindingAuditTarget(scope.row)"
type="primary"
size="small"
link
@click="openBindingAudit(scope.row)"
>
绑定审计
</ElButton>
</template> </template>
</ElTableColumn> </ElTableColumn>
</ElTable> </ElTable>
@@ -589,10 +610,14 @@
getExpiryEstimateTooltip, getExpiryEstimateTooltip,
getExpiryEstimateClass getExpiryEstimateClass
} from '@/utils/business/expiryEstimate' } from '@/utils/business/expiryEstimate'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
// Props // Props
interface BindingCard { interface BindingCard {
id: number id: number
card_id?: number
iccid: string iccid: string
msisdn?: string msisdn?: string
carrier_name?: string carrier_name?: string
@@ -609,7 +634,9 @@
} }
interface AssetInfo { interface AssetInfo {
asset_id: number
asset_type: 'card' | 'device' asset_type: 'card' | 'device'
identifier: string
iccid?: string iccid?: string
imei?: string imei?: string
msisdn?: string msisdn?: string
@@ -717,6 +744,64 @@
const { hasAuth } = useAuth() const { hasAuth } = useAuth()
const userStore = useUserStore() const userStore = useUserStore()
const isSubjectActivityView = computed(() => [3, 4].includes(Number(userStore.info.user_type)))
const auditTarget = computed(() => {
const userType = Number(userStore.info.user_type)
const permission =
userType === 3
? AUDIT_PERMISSIONS.agentActivity
: userType === 4
? AUDIT_PERMISSIONS.enterpriseActivity
: props.cardInfo.asset_type === 'card'
? AUDIT_PERMISSIONS.cardEntry
: AUDIT_PERMISSIONS.deviceEntry
if (!hasAuth(permission)) return null
return resolveAuditResourceTarget({
userType,
resourceType: props.cardInfo.asset_type === 'card' ? 'iot_card' : 'device',
internalId: props.cardInfo.asset_id,
businessIdentifier:
props.cardInfo.asset_type === 'card' ? props.cardInfo.iccid : props.cardInfo.virtual_no
})
})
const openAssetAudit = () => {
if (auditTarget.value) openAuditInvestigation(auditTarget.value)
}
const getBindingCardAuditTarget = (card: BindingCard) => {
const userType = Number(userStore.info.user_type)
const permission =
userType === 3
? AUDIT_PERMISSIONS.agentActivity
: userType === 4
? AUDIT_PERMISSIONS.enterpriseActivity
: AUDIT_PERMISSIONS.cardEntry
if (!hasAuth(permission)) return null
return resolveAuditResourceTarget({
userType,
resourceType: 'iot_card',
internalId: card.card_id,
businessIdentifier: card.iccid
})
}
const getBindingAuditTarget = (card: BindingCard) => {
if (
![1, 2].includes(Number(userStore.info.user_type)) ||
!hasAuth(AUDIT_PERMISSIONS.resourceTimeline)
)
return null
return resolveAuditResourceTarget({
resourceType: 'device_sim_binding',
internalId: card.id
})
}
const openBindingCardAudit = (card: BindingCard) => {
const target = getBindingCardAuditTarget(card)
if (target) openAuditInvestigation(target)
}
const openBindingAudit = (card: BindingCard) => {
const target = getBindingAuditTarget(card)
if (target) openAuditInvestigation(target)
}
const { width } = useWindowSize() const { width } = useWindowSize()
const CARD_MONTH_USAGE_PERMISSION = 'asset_info:view_card_month_usage' const CARD_MONTH_USAGE_PERMISSION = 'asset_info:view_card_month_usage'

View File

@@ -44,6 +44,20 @@
/> />
<ElButton type="primary" @click="handleQuery">查询</ElButton> <ElButton type="primary" @click="handleQuery">查询</ElButton>
<ElButton @click="handleReset()">重置</ElButton> <ElButton @click="handleReset()">重置</ElButton>
<ElButton
v-if="walletInfo?.wallet_id && hasAuth(AUDIT_PERMISSIONS.financeTimeline)"
@click="openWalletAudit"
>资金链路</ElButton
>
<ElButton
v-if="
walletInfo?.resource_type &&
walletInfo?.resource_id &&
hasAuth(AUDIT_PERMISSIONS.resourceTimeline)
"
@click="openAssetAudit"
>资产审计</ElButton
>
</div> </div>
</div> </div>
</template> </template>
@@ -167,6 +181,13 @@
AssetWalletTransactionParams, AssetWalletTransactionParams,
TransactionType TransactionType
} from '@/types/api' } from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import {
resolveAuditResourceTarget,
resolveFinanceAuditTarget
} from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
interface Props { interface Props {
assetIdentifier: string assetIdentifier: string
@@ -182,6 +203,19 @@
boundDeviceId: undefined, boundDeviceId: undefined,
boundDeviceNo: '' boundDeviceNo: ''
}) })
const { hasAuth } = useAuth()
const openWalletAudit = () => {
const target = resolveFinanceAuditTarget('wallet_id', props.walletInfo?.wallet_id)
if (target) openAuditInvestigation(target)
}
const openAssetAudit = () => {
const target = resolveAuditResourceTarget({
resourceType: props.walletInfo?.resource_type || '',
internalId: props.walletInfo?.resource_id
})
if (target) openAuditInvestigation(target)
}
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'navigateToDevice', deviceNo: string): void (e: 'navigateToDevice', deviceNo: string): void

View File

@@ -969,6 +969,10 @@
import { computed, h } from 'vue' import { computed, h } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import { useUserStore } from '@/store/modules/user'
import { import {
DeviceService, DeviceService,
ShopService, ShopService,
@@ -1027,6 +1031,7 @@
const { hasAuth } = useAuth() const { hasAuth } = useAuth()
const router = useRouter() const router = useRouter()
const userStore = useUserStore()
const loading = ref(false) const loading = ref(false)
const allocateLoading = ref(false) const allocateLoading = ref(false)
@@ -3173,6 +3178,27 @@
}) })
} }
const userType = Number(userStore.getUserInfo.user_type)
const auditPermission =
userType === 3
? AUDIT_PERMISSIONS.agentActivity
: userType === 4
? AUDIT_PERMISSIONS.enterpriseActivity
: AUDIT_PERMISSIONS.deviceEntry
if (hasAuth(auditPermission)) {
const auditTarget = resolveAuditResourceTarget({
userType,
resourceType: 'device',
internalId: row.id,
businessIdentifier: row.virtual_no
})
if (auditTarget)
moreActions.push({
label: userType >= 3 ? '活动记录' : '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
}
// 如果有更多操作,添加到 actions 中 // 如果有更多操作,添加到 actions 中
if (moreActions.length > 0) { if (moreActions.length > 0) {
actions.push(...moreActions) actions.push(...moreActions)

View File

@@ -10,6 +10,30 @@
返回 返回
</ElButton> </ElButton>
<h2 class="detail-title">换货单详情</h2> <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> </div>
<!-- 详情内容 --> <!-- 详情内容 -->
@@ -35,15 +59,51 @@
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import DetailPage from '@/components/common/DetailPage.vue' import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } 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' }) defineOptions({ name: 'ExchangeDetail' })
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { hasAuth } = useAuth()
const userStore = useUserStore()
const loading = ref(false) const loading = ref(false)
const exchangeDetail = ref<ExchangeResponse | null>(null) const exchangeDetail = ref<ExchangeResponse | null>(null)
const exchangeId = ref<number>(0) 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) => { const formatExchangeShopName = (shopName?: string | null, shopId?: number | null) => {
if (shopName) return shopName if (shopName) return shopName

View File

@@ -35,7 +35,7 @@
:total="pagination.total" :total="pagination.total"
:marginTop="10" :marginTop="10"
:actions="getActions" :actions="getActions"
:actionsWidth="120" :actionsWidth="160"
@size-change="handleSizeChange" @size-change="handleSizeChange"
@current-change="handleCurrentChange" @current-change="handleCurrentChange"
> >
@@ -438,10 +438,15 @@
getProcessingStatusText getProcessingStatusText
} from '@/utils/business/approvalSummary' } from '@/utils/business/approvalSummary'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
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: 'ExchangeManagement' }) defineOptions({ name: 'ExchangeManagement' })
const { hasAuth } = useAuth() const { hasAuth } = useAuth()
const userStore = useUserStore()
const router = useRouter() const router = useRouter()
const currentExchangeRow = ref<ExchangeResponse | null>(null) const currentExchangeRow = ref<ExchangeResponse | null>(null)
@@ -1548,6 +1553,45 @@
const actions: any[] = [] const actions: any[] = []
const status = row.status const status = row.status
const flowType = row.flow_type || 'shipping' // 历史数据兼容 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)) {
const exchangeTarget = resolveAuditResourceTarget({
userType,
resourceType: 'exchange_order',
internalId: row.id,
businessIdentifier: row.exchange_no
})
if (exchangeTarget)
actions.push({
label: userType === 3 ? '活动记录' : '换货审计',
handler: () => openAuditInvestigation(exchangeTarget),
type: 'primary'
})
if ([1, 2].includes(userType)) {
const oldAssetTarget = resolveAuditResourceTarget({
resourceType: row.old_asset_type,
internalId: row.old_asset_id
})
if (oldAssetTarget)
actions.push({
label: '旧资产审计',
handler: () => openAuditInvestigation(oldAssetTarget),
type: 'primary'
})
const newAssetTarget = resolveAuditResourceTarget({
resourceType: row.new_asset_type || '',
internalId: row.new_asset_id
})
if (newAssetTarget)
actions.push({
label: '新资产审计',
handler: () => openAuditInvestigation(newAssetTarget),
type: 'primary'
})
}
}
// 直接换货不支持任何操作(发货、确认完成、取消) // 直接换货不支持任何操作(发货、确认完成、取消)
if (flowType === 'direct') { if (flowType === 'direct') {

View File

@@ -1024,6 +1024,9 @@
import { computed, h } from 'vue' import { computed, h } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveAuditResourceTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import { import {
CardService, CardService,
ShopService, ShopService,
@@ -2331,6 +2334,27 @@
}) })
} }
const userType = Number(userStore.getUserInfo.user_type)
const auditPermission =
userType === 3
? AUDIT_PERMISSIONS.agentActivity
: userType === 4
? AUDIT_PERMISSIONS.enterpriseActivity
: AUDIT_PERMISSIONS.cardEntry
if (hasAuth(auditPermission)) {
const auditTarget = resolveAuditResourceTarget({
userType,
resourceType: 'iot_card',
internalId: row.id,
businessIdentifier: row.iccid
})
if (auditTarget)
actions.push({
label: userType >= 3 ? '活动记录' : '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
}
return actions return actions
} }
@@ -2448,8 +2472,8 @@
return return
} }
const successCount = res.data?.success_count ?? assetIds.length const successCount = res.data?.success_count ?? assetIds.length
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`) ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
batchRealnamePolicyDialogVisible.value = false batchRealnamePolicyDialogVisible.value = false
selectedCards.value = [] selectedCards.value = []
await getTableData() await getTableData()

View File

@@ -10,6 +10,22 @@
返回 返回
</ElButton> </ElButton>
<h2 class="detail-title">资产分配详情</h2> <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> </div>
<!-- 详情内容 --> <!-- 详情内容 -->
@@ -25,7 +41,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElCard, ElButton, ElIcon, ElMessage } from 'element-plus' import { ElCard, ElButton, ElIcon, ElMessage } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue' import { ArrowLeft, Loading } from '@element-plus/icons-vue'
@@ -34,14 +50,42 @@
import { CardService } from '@/api/modules' import { CardService } from '@/api/modules'
import type { AssetAllocationRecord } from '@/types/api/card' import type { AssetAllocationRecord } from '@/types/api/card'
import { formatDateTime } from '@/utils/business/format' 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' }) defineOptions({ name: 'AssetAssignDetail' })
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { hasAuth } = useAuth()
const userStore = useUserStore()
const loading = ref(false) const loading = ref(false)
const detailData = ref<AssetAllocationRecord | null>(null) 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[] = [ const detailSections: DetailSection[] = [

View File

@@ -28,6 +28,8 @@
:pageSize="pagination.pageSize" :pageSize="pagination.pageSize"
:total="pagination.total" :total="pagination.total"
:marginTop="10" :marginTop="10"
:actions="getActions"
:actionsWidth="180"
@size-change="handleSizeChange" @size-change="handleSizeChange"
@current-change="handleCurrentChange" @current-change="handleCurrentChange"
> >
@@ -51,10 +53,15 @@
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import type { AssetAllocationRecord, AllocationTypeEnum, AssetTypeEnum } from '@/types/api/card' import type { AssetAllocationRecord, AllocationTypeEnum, AssetTypeEnum } from '@/types/api/card'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
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: 'AssetAllocationRecords' }) defineOptions({ name: 'AssetAllocationRecords' })
const { hasAuth } = useAuth() const { hasAuth } = useAuth()
const userStore = useUserStore()
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
@@ -221,6 +228,40 @@
const recordList = ref<AssetAllocationRecord[]>([]) const recordList = ref<AssetAllocationRecord[]>([])
const getActions = (row: AssetAllocationRecord) => {
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
const recordTarget = resolveAuditResourceTarget({
userType,
resourceType: 'asset_allocation_record',
internalId: row.id,
businessIdentifier: row.allocation_no
})
if (recordTarget)
actions.push({
label: userType === 3 ? '活动记录' : '分配审计',
handler: () => openAuditInvestigation(recordTarget),
type: 'primary'
})
if ([1, 2].includes(userType)) {
const assetTarget = resolveAuditResourceTarget({
userType,
resourceType: row.asset_type,
internalId: row.asset_id
})
if (assetTarget)
actions.push({
label: '资产审计',
handler: () => openAuditInvestigation(assetTarget),
type: 'primary'
})
}
return actions
}
// 获取分配类型标签类型 // 获取分配类型标签类型
const getAllocationTypeType = (type: AllocationTypeEnum) => { const getAllocationTypeType = (type: AllocationTypeEnum) => {
return type === 'allocate' ? 'success' : 'warning' return type === 'allocate' ? 'success' : 'warning'

View File

@@ -0,0 +1,323 @@
<template>
<div class="detail-page">
<ElCard shadow="never">
<div class="detail-header">
<ElButton @click="router.back()"
><template #icon
><ElIcon><ArrowLeft /></ElIcon></template
>返回</ElButton
>
<h2 class="detail-title">审计事件详情</h2>
<ElTag v-if="detail" :type="auditRiskMeta[detail.risk_level]?.type"
>{{ auditRiskMeta[detail.risk_level]?.label || detail.risk_level }}风险</ElTag
>
</div>
<div v-loading="loading">
<template v-if="detail">
<ElCard shadow="never" class="section-card">
<ElDescriptions :column="3" border>
<ElDescriptionsItem label="分类">{{
auditCategoryLabels[detail.category] || detail.category
}}</ElDescriptionsItem>
<ElDescriptionsItem label="动作">{{ detail.action_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="结果"
><ElTag :type="auditResultMeta[detail.result]?.type">{{
auditResultMeta[detail.result]?.label
}}</ElTag></ElDescriptionsItem
>
<ElDescriptionsItem label="来源">{{
auditSourceLabels[detail.source] || detail.source
}}</ElDescriptionsItem>
<ElDescriptionsItem label="发生时间">{{
formatDateTime(detail.occurred_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="写入时间">{{
formatDateTime(detail.created_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="业务范围">{{ scopeDisplay }}</ElDescriptionsItem>
<ElDescriptionsItem label="摘要" :span="3">{{ detail.summary }}</ElDescriptionsItem>
<ElDescriptionsItem
v-if="detail.error_code || detail.error_summary"
label="错误信息"
:span="3"
>
<code v-if="detail.error_code">{{ detail.error_code }}</code>
<span v-if="detail.error_code && detail.error_summary"> · </span>
<span>{{ detail.error_summary }}</span>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<div class="detail-grid" :class="{ 'detail-grid--single': !userStore.isSuperAdmin }">
<ElCard shadow="never"
><template #header>操作者快照</template
><ElDescriptions :column="1"
><ElDescriptionsItem label="名称">{{ detail.actor_name || '-' }}</ElDescriptionsItem
><ElDescriptionsItem label="类型">{{
auditActorKindLabels[detail.actor_kind] || detail.actor_kind
}}</ElDescriptionsItem
><ElDescriptionsItem label="店铺">{{
namedId(detail.actor_shop_name, detail.actor_shop_id)
}}</ElDescriptionsItem
><ElDescriptionsItem label="企业">{{
namedId(detail.actor_enterprise_name, detail.actor_enterprise_id)
}}</ElDescriptionsItem></ElDescriptions
></ElCard
>
<ElCard v-if="userStore.isSuperAdmin" shadow="never"
><template #header>请求上下文</template
><ElDescriptions :column="1"
><ElDescriptionsItem label="请求">{{
[detail.request_method, detail.request_path].filter(Boolean).join(' ') || '-'
}}</ElDescriptionsItem
><ElDescriptionsItem label="request_id">{{
detail.request_id || '-'
}}</ElDescriptionsItem
><ElDescriptionsItem label="correlation_id">{{
detail.correlation_id || '-'
}}</ElDescriptionsItem
><ElDescriptionsItem label="IP">{{ detail.ip_address || '-' }}</ElDescriptionsItem
><ElDescriptionsItem label="User-Agent">{{
detail.user_agent || '-'
}}</ElDescriptionsItem></ElDescriptions
></ElCard
>
</div>
<ElCard v-if="hasBatchData" shadow="never" class="section-card">
<template #header>批次执行统计</template>
<ElDescriptions :column="3" border>
<ElDescriptionsItem label="处理总数">{{
detail.batch_total || 0
}}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">{{
detail.success_count || 0
}}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">{{ detail.fail_count || 0 }}</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<ElCard shadow="never" class="section-card"
><template #header>调查引用</template
><AuditInvestigationLinks
:refs="detail.investigation_refs"
:current-event-id="detail.event_id"
/></ElCard>
<ElCard shadow="never" class="section-card">
<template #header>相关资源与快照</template>
<ElTable :data="detail.resources || []" :row-key="resourceRowKey">
<ElTableColumn type="expand" width="48">
<template #default="{ row }">
<div class="resource-snapshot-grid">
<section>
<h4>身份快照</h4>
<pre>{{ auditJson(row.identity_snapshot) }}</pre>
</section>
<section>
<h4>变更前</h4>
<pre>{{ auditJson(row.before_data) }}</pre>
</section>
<section>
<h4>变更后</h4>
<pre>{{ auditJson(row.after_data) }}</pre>
</section>
<section>
<h4>主体安全数据</h4>
<pre>{{ auditJson(row.subject_data) }}</pre>
</section>
</div>
</template>
</ElTableColumn>
<ElTableColumn
prop="display_name"
label="资源"
min-width="180"
show-overflow-tooltip
/>
<ElTableColumn label="类型" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{
auditResourceTypeLabels[row.resource_type] || row.resource_type
}}</template>
</ElTableColumn>
<ElTableColumn label="关系" width="110">
<template #default="{ row }">{{
auditResourceRelationLabels[row.relation] || row.relation
}}</template>
</ElTableColumn>
<ElTableColumn prop="role" label="业务角色" min-width="150" />
<ElTableColumn label="主体可见性" min-width="150">
<template #default="{ row }">{{
auditSubjectVisibilityLabels[row.subject_visibility] ||
row.subject_visibility ||
'-'
}}</template>
</ElTableColumn>
<ElTableColumn
prop="subject_summary"
label="主体摘要"
min-width="220"
show-overflow-tooltip
/>
<ElTableColumn label="写入时间" width="180">
<template #default="{ row }">{{
row.created_at ? formatDateTime(row.created_at) : '-'
}}</template>
</ElTableColumn>
</ElTable>
</ElCard>
<ElCard v-if="userStore.isSuperAdmin" shadow="never">
<template #header>元数据</template>
<pre>{{ auditJson(detail.metadata) }}</pre>
</ElCard>
</template>
<ElEmpty v-else-if="!loading" description="未找到审计事件" />
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { AuditService } from '@/api/modules'
import type { AuditEventDetail, AuditResourceView } from '@/types/api'
import {
auditActorKindLabels,
auditCategoryLabels,
auditJson,
auditResourceRelationLabels,
auditResourceTypeLabels,
auditResultMeta,
auditRiskMeta,
auditScopeTypeLabels,
auditSourceLabels,
auditSubjectVisibilityLabels
} from '@/utils/business/audit'
import { formatDateTime } from '@/utils/business/format'
import { useUserStore } from '@/store/modules/user'
import AuditInvestigationLinks from '@/components/business/audit/AuditInvestigationLinks.vue'
defineOptions({ name: 'AuditEventDetail' })
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const detail = ref<AuditEventDetail>()
const scopeDisplay = computed(() => {
if (!detail.value) return '-'
const type = auditScopeTypeLabels[detail.value.scope_type] || detail.value.scope_type
const identity = detail.value.scope_name || detail.value.scope_id
return identity ? `${type} · ${identity}` : type
})
const hasBatchData = computed(() =>
Boolean(
(detail.value?.batch_total || 0) > 0 ||
(detail.value?.success_count || 0) > 0 ||
(detail.value?.fail_count || 0) > 0
)
)
const namedId = (name?: string | null, id?: number | null) => {
if (name && id !== null && id !== undefined) return `${name}ID: ${id}`
return name || (id !== null && id !== undefined ? `ID: ${id}` : '-')
}
const resourceRowKey = (row: AuditResourceView) =>
[row.resource_type, row.resource_id || row.resource_key || row.sort_order || row.display_name]
.filter((item) => item !== null && item !== undefined && item !== '')
.join(':')
onMounted(async () => {
const id = String(route.params.eventId || '')
if (!id) return
loading.value = true
try {
detail.value = (await AuditService.getEventDetail(id)).data
} catch {
ElMessage.error('审计事件详情加载失败')
} finally {
loading.value = false
}
})
</script>
<style scoped>
.detail-page {
padding: 20px;
}
.detail-header {
display: flex;
gap: 16px;
align-items: center;
padding-bottom: 16px;
}
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.detail-header .el-tag {
margin-left: auto;
}
.section-card {
margin-bottom: 16px;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.detail-grid--single {
grid-template-columns: 1fr;
}
.resource-snapshot-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 8px 16px 16px;
}
.resource-snapshot-grid h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.resource-snapshot-grid pre {
max-height: 360px;
}
code,
pre {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
pre {
padding: 12px;
margin: 0;
overflow: auto;
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
border-radius: 6px;
}
@media (width <= 768px) {
.detail-page {
padding: 12px;
}
.detail-grid {
grid-template-columns: 1fr;
}
.resource-snapshot-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,310 @@
<template>
<ArtTableFullScreen>
<div class="audit-events-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
show-expand
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:column-list="columnOptions"
v-model:columns="columnChecks"
@refresh="load"
/>
<ArtTable
row-key="event_id"
:loading="loading"
:data="items"
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:margin-top="10"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElButton, ElMessage, ElTag } from 'element-plus'
import { AuditService } from '@/api/modules'
import type { AuditEventQuery, AuditEventView } from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { auditResultMeta, auditRiskMeta } from '@/utils/business/audit'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'AuditEvents' })
const router = useRouter()
const loading = ref(false)
const items = ref<AuditEventView[]>([])
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const initialSearchState = {
dateRange: [] as string[],
category: undefined,
result: undefined,
risk: undefined,
source: undefined,
actor_kind: undefined,
scope_type: undefined
}
let searchForm = reactive({ ...initialSearchState })
const categoryOptions = [
{ label: '配置', value: 'configuration' },
{ label: '可靠性', value: 'reliability' },
{ label: '资产', value: 'asset' },
{ label: '安全', value: 'security' },
{ label: '身份', value: 'identity' },
{ label: '业务', value: 'business' }
]
const resultOptions = [
{ label: '成功', value: 'success' },
{ label: '失败', value: 'failed' },
{ label: '拒绝', value: 'denied' },
{ label: '部分成功', value: 'partial' },
{ label: '未知', value: 'unknown' }
]
const riskOptions = [
{ label: '低', value: 'low' },
{ label: '普通', value: 'normal' },
{ label: '高', value: 'high' },
{ label: '严重', value: 'critical' }
]
const sourceOptions = [
{ label: '后台管理 API', value: 'admin_api' },
{ label: '个人客户 API', value: 'personal_api' },
{ label: '代理 OpenAPI', value: 'openapi' },
{ label: '异步 Worker', value: 'worker' },
{ label: '计划任务', value: 'scheduler' },
{ label: '外部系统回调', value: 'callback' }
]
const sourceLabels: Record<string, string> = Object.fromEntries(
sourceOptions.map((item) => [item.value, item.label])
)
const actorOptions = [
{ label: '人工账号', value: 'account' },
{ label: '个人客户', value: 'personal_customer' },
{ label: '开放接口账号', value: 'openapi' },
{ label: '系统任务', value: 'system_task' },
{ label: '计划任务', value: 'scheduled_job' },
{ label: '外部系统', value: 'external_system' }
]
const scopeOptions = [
{ label: '平台', value: 'platform' },
{ label: '店铺', value: 'shop' },
{ label: '个人客户', value: 'personal_customer' }
]
const searchFormItems: SearchFormItem[] = [
{
label: '起止时间',
prop: 'dateRange',
type: 'date',
config: {
type: 'daterange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DD'
}
},
{
label: '类别',
prop: 'category',
type: 'select',
options: categoryOptions,
config: { clearable: true }
},
{
label: '结果',
prop: 'result',
type: 'select',
options: resultOptions,
config: { clearable: true }
},
{
label: '风险',
prop: 'risk',
type: 'select',
options: riskOptions,
config: { clearable: true }
},
{
label: '来源',
prop: 'source',
type: 'select',
options: sourceOptions,
config: { clearable: true }
},
{
label: '操作者',
prop: 'actor_kind',
type: 'select',
options: actorOptions,
config: { clearable: true }
},
{
label: '业务范围',
prop: 'scope_type',
type: 'select',
options: scopeOptions,
config: { clearable: true }
}
]
const columnOptions = [
{ label: '发生时间', prop: 'occurred_at' },
{ label: '动作', prop: 'action_name' },
{ label: '摘要', prop: 'summary' },
{ label: '主要资源', prop: 'primary_resource' },
{ label: '操作者', prop: 'actor_name' },
{ label: '来源', prop: 'source' },
{ label: '结果', prop: 'result' },
{ label: '风险', prop: 'risk_level' },
{ label: '操作', prop: 'actions' }
]
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'occurred_at',
label: '发生时间',
width: 180,
formatter: (row: AuditEventView) => formatDateTime(row.occurred_at)
},
{
prop: 'action_name',
label: '动作',
minWidth: 220,
formatter: (row: AuditEventView) => row.action_name || '-'
},
{ prop: 'summary', label: '摘要', minWidth: 240, showOverflowTooltip: true },
{
prop: 'primary_resource',
label: '主要资源',
minWidth: 210,
showOverflowTooltip: true,
formatter: (row: AuditEventView) => {
const resource =
row.resources?.find((item) => item.relation === 'primary') || row.resources?.[0]
if (!resource) return '-'
return resource.display_name || resource.resource_key || resource.resource_id || '-'
}
},
{
prop: 'actor_name',
label: '操作者',
minWidth: 170,
formatter: (row: AuditEventView) => row.actor_name || row.actor_id || '-'
},
{
prop: 'source',
label: '来源',
showOverflowTooltip: true,
width: 130,
formatter: (row: AuditEventView) => sourceLabels[row.source] || row.source
},
{
prop: 'result',
label: '结果',
width: 100,
formatter: (row: AuditEventView) =>
h(
ElTag,
{ type: auditResultMeta[row.result]?.type || 'info' },
() => auditResultMeta[row.result]?.label || row.result
)
},
{
prop: 'risk_level',
label: '风险',
width: 90,
formatter: (row: AuditEventView) =>
h(
ElTag,
{ type: auditRiskMeta[row.risk_level]?.type || 'info', effect: 'plain' },
() => auditRiskMeta[row.risk_level]?.label || row.risk_level
)
},
{
prop: 'actions',
label: '操作',
width: 110,
fixed: 'right',
formatter: (row: AuditEventView) =>
h(
ElButton,
{ type: 'primary', link: true, onClick: () => openDetail(row) },
() => '查看详情'
)
}
])
const toAuditDate = (value?: string, endExclusive = false) => {
if (!value) return undefined
const [year, month, day] = value.split('-').map(Number)
const date = new Date(year, month - 1, day + (endExclusive ? 1 : 0))
return date.toISOString()
}
const buildQuery = (): AuditEventQuery => ({
page: pagination.page,
page_size: pagination.pageSize,
created_from: toAuditDate(searchForm.dateRange?.[0]),
created_to: toAuditDate(searchForm.dateRange?.[1], true),
category: searchForm.category,
result: searchForm.result,
risk: searchForm.risk,
source: searchForm.source,
actor_kind: searchForm.actor_kind,
scope_type: searchForm.scope_type
})
const load = async () => {
loading.value = true
try {
const data = (await AuditService.getEvents(buildQuery())).data
items.value = data.items
pagination.total = data.total
} catch {
ElMessage.error('审计事件加载失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
load()
}
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState, dateRange: [] })
pagination.page = 1
load()
}
const handleSizeChange = (size: number) => {
pagination.pageSize = size
load()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
load()
}
const openDetail = (row: AuditEventView) =>
router.push(`/audit/events/${encodeURIComponent(row.event_id)}`)
onMounted(load)
</script>
<style scoped lang="scss">
.audit-events-page {
height: 100%;
}
</style>

View File

@@ -0,0 +1,399 @@
<template>
<div class="integration-detail-page">
<ElCard shadow="never">
<div class="detail-header">
<ElButton @click="router.back()">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">外部集成交互详情</h2>
<ElTag
v-if="detail"
class="result-tag"
:type="integrationCategoryMeta[detail.result.category]?.type || 'info'"
>
{{ auditResultDisplay(detail.result.code, detail.result.name) }}
</ElTag>
</div>
<div v-loading="loading">
<template v-if="detail">
<ElCard shadow="never" class="section-card">
<template #header>交互概览</template>
<ElDescriptions :column="3" border>
<ElDescriptionsItem label="提供方">
{{ detail.identity.provider_name || detail.identity.provider }}
</ElDescriptionsItem>
<ElDescriptionsItem label="方向">
{{ detail.identity.direction_name || detail.identity.direction }}
</ElDescriptionsItem>
<ElDescriptionsItem label="操作">
{{ detail.identity.operation_name || detail.identity.operation }}
</ElDescriptionsItem>
<ElDescriptionsItem label="结果">
<ElTag :type="integrationCategoryMeta[detail.result.category]?.type || 'info'">
{{ auditResultDisplay(detail.result.code, detail.result.name) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="结果类别">
{{
integrationCategoryMeta[detail.result.category]?.label || detail.result.category
}}
</ElDescriptionsItem>
<ElDescriptionsItem label="耗时"
>{{ detail.result.duration_ms }} ms</ElDescriptionsItem
>
<ElDescriptionsItem label="HTTP 状态">
{{ detail.result.http_status ?? '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="外部结果码">
{{ detail.result.provider_code || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="状态变更">
{{ booleanText(detail.result.state_changed) }}
</ElDescriptionsItem>
<ElDescriptionsItem v-if="detail.identity.external_id" label="外部业务标识" :span="3">
{{ detail.identity.external_id }}
</ElDescriptionsItem>
<ElDescriptionsItem
v-if="detail.result.provider_message"
label="外部结果摘要"
:span="3"
>
{{ detail.result.provider_message }}
</ElDescriptionsItem>
<ElDescriptionsItem
v-if="detail.result.recovery_strategy"
label="恢复策略说明"
:span="3"
>
{{ detail.result.recovery_strategy }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<div class="detail-grid section-card">
<ElCard v-if="hasResource" shadow="never">
<template #header>关联资源</template>
<ElDescriptions :column="1">
<ElDescriptionsItem label="资源类型">
{{
auditResourceTypeLabels[detail.resource?.type || ''] ||
detail.resource?.type ||
'-'
}}
</ElDescriptionsItem>
<ElDescriptionsItem label="业务标识">
{{ detail.resource?.key || '-' }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<ElCard shadow="never">
<template #header>调查入口</template>
<div v-if="hasAssociations" class="link-actions">
<ElButton
v-if="detail.linkage.request_id"
type="primary"
plain
@click="
openAuditInvestigation({ mode: 'request', id: detail.linkage.request_id })
"
>
请求链路
</ElButton>
<ElButton
v-if="detail.linkage.correlation_id"
type="primary"
plain
@click="
openAuditInvestigation({
mode: 'correlation',
id: detail.linkage.correlation_id
})
"
>
业务关联链路
</ElButton>
<ElButton
v-if="detail.resource?.type && detail.resource?.id"
type="primary"
plain
@click="openResourceTimeline"
>
资源审计时间线
</ElButton>
</div>
<span v-else class="muted">无可用调查引用</span>
<ElAlert
v-if="hasUnavailableFidelity"
class="fidelity-alert"
type="warning"
:closable="false"
title="部分关联缺少可靠标识,已隐藏相应入口。"
/>
</ElCard>
</div>
<div class="detail-grid section-card">
<ElCard shadow="never">
<template #header>触发信息</template>
<ElDescriptions :column="1">
<ElDescriptionsItem label="触发来源">
{{ detail.trigger.source || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="业务场景">
{{ detail.trigger.scene || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="尝试序号">
{{ detail.trigger.attempt }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<ElCard shadow="never">
<template #header>时间信息</template>
<ElDescriptions :column="1">
<ElDescriptionsItem label="创建时间">
{{ formatDateTime(detail.timestamps.created_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="计划时间">
{{ formatOptionalDate(detail.timestamps.scheduled_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">
{{ formatOptionalDate(detail.timestamps.started_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">
{{ formatDateTime(detail.timestamps.updated_at) }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</div>
<ElCard shadow="never" class="section-card">
<template #header>关联可靠性</template>
<ElDescriptions :column="3" border>
<ElDescriptionsItem label="尝试序列可靠">
{{ booleanText(detail.fidelity.attempt_sequence_reliable) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="业务链路可用">
{{ booleanText(detail.fidelity.correlation_available) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="资源引用可用">
{{ booleanText(detail.fidelity.resource_id_available) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="重试序列可用">
{{ booleanText(detail.fidelity.trigger_series_available) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="外部消息保真度" :span="2">
{{ detail.fidelity.provider_message_fidelity || '-' }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<div v-if="hasContentSummary" class="content-grid section-card">
<ElCard v-if="hasJsonContent(detail.content.request_summary)" shadow="never">
<template #header>请求摘要</template>
<pre>{{ auditJson(detail.content.request_summary) }}</pre>
</ElCard>
<ElCard v-if="hasJsonContent(detail.content.response_summary)" shadow="never">
<template #header>响应摘要</template>
<pre>{{ auditJson(detail.content.response_summary) }}</pre>
</ElCard>
<ElCard v-if="hasJsonContent(detail.content.metadata)" shadow="never">
<template #header>扩展信息</template>
<pre>{{ auditJson(detail.content.metadata) }}</pre>
</ElCard>
</div>
<ElCard v-if="detail.attempts?.length" shadow="never">
<template #header>尝试记录</template>
<ArtTable :data="detail.attempts" :pagination="false" :margin-top="0">
<template #default>
<ElTableColumn prop="attempt" label="序号" width="80" />
<ElTableColumn label="创建时间" width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</ElTableColumn>
<ElTableColumn label="操作" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.operation_name || row.operation }}</template>
</ElTableColumn>
<ElTableColumn label="结果" width="110">
<template #default="{ row }">
<ElTag :type="integrationCategoryType(row.result_category)">
{{ auditResultDisplay(row.result, row.result_name) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="耗时" width="100">
<template #default="{ row }">{{ row.duration_ms }} ms</template>
</ElTableColumn>
<ElTableColumn label="已发送" width="90">
<template #default="{ row }">{{ booleanText(row.sent) }}</template>
</ElTableColumn>
<ElTableColumn label="状态变更" width="100">
<template #default="{ row }">{{ booleanText(row.state_changed) }}</template>
</ElTableColumn>
</template>
</ArtTable>
</ElCard>
</template>
<ElEmpty v-else-if="!loading" description="未找到外部集成交互记录" />
</div>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { AuditService } from '@/api/modules'
import type { IntegrationDetailResponse } from '@/types/api'
import {
auditJson,
auditResourceTypeLabels,
auditResultDisplay,
integrationCategoryMeta
} from '@/utils/business/audit'
import { formatDateTime } from '@/utils/business/format'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
defineOptions({ name: 'AuditIntegrationDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detail = ref<IntegrationDetailResponse>()
const hasUnavailableFidelity = computed(() =>
Object.values(detail.value?.fidelity || {}).some((value) => value === false)
)
const hasResource = computed(() =>
Boolean(detail.value?.resource?.type || detail.value?.resource?.key)
)
const hasAssociations = computed(() =>
Boolean(
detail.value?.linkage.request_id ||
detail.value?.linkage.correlation_id ||
(detail.value?.resource?.type && detail.value?.resource?.id)
)
)
const hasJsonContent = (value?: Record<string, unknown> | null) =>
Boolean(value && Object.keys(value).length)
const hasContentSummary = computed(() =>
Boolean(
hasJsonContent(detail.value?.content.request_summary) ||
hasJsonContent(detail.value?.content.response_summary) ||
hasJsonContent(detail.value?.content.metadata)
)
)
const booleanText = (value: boolean) => (value ? '是' : '否')
const integrationCategoryType = (category: unknown) =>
integrationCategoryMeta[category as keyof typeof integrationCategoryMeta]?.type || 'info'
const formatOptionalDate = (value?: string | null) => (value ? formatDateTime(value) : '-')
const openResourceTimeline = () => {
if (!detail.value?.resource?.type || !detail.value.resource.id) return
openAuditInvestigation({
mode: 'resource',
resourceType: detail.value.resource.type,
id: detail.value.resource.id
})
}
onMounted(async () => {
const id = String(route.params.integrationId || '')
if (!id) return
loading.value = true
try {
detail.value = (await AuditService.getIntegrationDetail(id)).data
} catch {
ElMessage.error('外部集成交互详情加载失败')
} finally {
loading.value = false
}
})
</script>
<style scoped lang="scss">
.integration-detail-page {
padding: 20px;
}
.detail-header {
display: flex;
gap: 16px;
align-items: center;
padding-bottom: 16px;
}
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.result-tag {
margin-left: auto;
}
.section-card {
margin-bottom: 16px;
}
.detail-grid,
.content-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.content-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.link-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.muted {
color: var(--el-text-color-secondary);
}
.fidelity-alert {
margin-top: 12px;
}
pre {
max-height: 360px;
padding: 12px;
margin: 0;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
line-height: 1.6;
overflow-wrap: anywhere;
white-space: pre-wrap;
background: var(--el-fill-color-light);
border-radius: 4px;
}
@media (width <= 992px) {
.content-grid {
grid-template-columns: 1fr;
}
}
@media (width <= 768px) {
.integration-detail-page {
padding: 12px;
}
.detail-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,457 @@
<template>
<ArtTableFullScreen class="integration-scroll-container">
<div class="integration-page" id="table-full-screen">
<ElCard shadow="never" class="filter-card">
<ElForm>
<ElRow :gutter="16" class="filter-grid">
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElDatePicker
v-model="dateRange"
type="datetimerange"
value-format="YYYY-MM-DDTHH:mm:ssZ"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElSelect v-model="query.provider" clearable placeholder="请选择提供方">
<ElOption
v-for="item in overview?.providers || []"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElSelect v-model="query.direction" clearable placeholder="请选择方向">
<ElOption
v-for="item in overview?.directions || []"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElSelect v-model="query.result" clearable placeholder="请选择结果">
<ElOption
v-for="item in overview?.results || []"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElButton type="primary" :icon="Search" @click="search">查询</ElButton>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</ElCard>
<div v-if="overview" class="metric-grid"
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--primary"
><ElIcon><Connection /></ElIcon></span
><div
><span>交互总数</span><strong>{{ overview.total }}</strong></div
></div
></ElCard
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--danger"
><ElIcon><WarningFilled /></ElIcon></span
><div
><span>异常数</span><strong class="danger">{{ overview.anomaly_count }}</strong></div
></div
></ElCard
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--success"
><ElIcon><Refresh /></ElIcon></span
><div
><span>状态变更</span><strong>{{ overview.state_changed_count }}</strong></div
></div
></ElCard
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--warning"
><ElIcon><Timer /></ElIcon></span
><div
><span>P95 耗时</span><strong>{{ overview.p95_duration_ms }} ms</strong></div
></div
></ElCard
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--info"
><ElIcon><QuestionFilled /></ElIcon></span
><div
><span>结果不明</span><strong>{{ overview.unknown_count }}</strong></div
></div
></ElCard
></div
>
<div v-if="overview" class="overview-grid">
<ElCard shadow="never">
<template #header>
<div class="section-title"
><ElIcon><Box /></ElIcon><span>提供方分布</span></div
>
</template>
<AuditDistributionChart
title="提供方分布"
:data="providerChartData"
@select="apply('provider', $event)"
/>
</ElCard>
<ElCard shadow="never">
<template #header>
<div class="section-title"
><ElIcon><Switch /></ElIcon><span>方向分布</span></div
>
</template>
<AuditDistributionChart
title="方向分布"
:data="directionChartData"
@select="apply('direction', $event)"
/>
</ElCard>
<ElCard shadow="never">
<template #header>
<div class="section-title"
><ElIcon><CircleCheckFilled /></ElIcon><span>结果分布</span></div
>
</template>
<AuditDistributionChart
title="结果分布"
type="bar"
:data="resultChartData"
@select="apply('result', $event)"
/>
</ElCard>
</div>
<ElCard shadow="never" class="art-table-card"
><ArtTableHeader v-model:columns="headerColumns" @refresh="load"
><template #left
><span class="section-title"
><ElIcon><List /></ElIcon><span>外部交互记录</span></span
></template
></ArtTableHeader
><ArtTable
:data="items"
:loading="loading"
row-key="integration_id"
:current-page="query.page"
:page-size="query.page_size"
:total="total"
:margin-top="16"
@size-change="
(size) => {
query.page_size = size
loadList()
}
"
@current-change="
(current) => {
query.page = current
loadList()
}
"
><template #default
><ElTableColumn label="时间" width="180"
><template #default="{ row }">{{
formatDateTime(row.created_at)
}}</template></ElTableColumn
><ElTableColumn label="提供方" min-width="130"
><template #default="{ row }">{{
row.provider_name || row.provider
}}</template></ElTableColumn
><ElTableColumn label="方向" prop="direction_name" width="90" /><ElTableColumn
label="操作"
min-width="190"
><template #default="{ row }">{{
row.operation_name || row.operation
}}</template></ElTableColumn
><ElTableColumn label="结果" width="120"
><template #default="{ row }"
><ElTag :type="categoryMeta(row).type">{{
auditResultDisplay(row.result, row.result_name)
}}</ElTag></template
></ElTableColumn
><ElTableColumn label="耗时" width="100"
><template #default="{ row }">{{
row.duration_ms == null ? '-' : `${row.duration_ms} ms`
}}</template></ElTableColumn
><ElTableColumn label="资源" min-width="150"
><template #default="{ row }">{{
row.resource?.key || row.resource?.id || '-'
}}</template></ElTableColumn
><ElTableColumn label="操作" width="100" fixed="right"
><template #default="{ row }"
><ElButton
link
type="primary"
@click="
router.push(`/audit/integrations/${encodeURIComponent(row.integration_id)}`)
"
>查看详情</ElButton
></template
></ElTableColumn
></template
></ArtTable
></ElCard
>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Box,
CircleCheckFilled,
Connection,
List,
QuestionFilled,
Refresh,
Search,
Switch,
Timer,
WarningFilled
} from '@element-plus/icons-vue'
import { AuditService } from '@/api/modules'
import type {
AuditNamedCount,
IntegrationListItem,
IntegrationOverview,
IntegrationQuery
} from '@/types/api'
import { auditResultDisplay, integrationCategoryMeta } from '@/utils/business/audit'
import { formatDateTime } from '@/utils/business/format'
import AuditDistributionChart from '@/components/business/audit/AuditDistributionChart.vue'
defineOptions({ name: 'AuditIntegrations' })
const router = useRouter()
const loading = ref(false)
const headerColumns = ref([])
const dateRange = ref<string[]>([])
const query = reactive<IntegrationQuery>({ page: 1, page_size: 20 })
const overview = ref<IntegrationOverview>()
const items = ref<IntegrationListItem[]>([])
const total = ref(0)
const aggregateChartData = (values: AuditNamedCount[]) => {
const totals = new Map<string, AuditNamedCount>()
values.forEach((item) => {
const key = item.name || item.code
const current = totals.get(key)
if (current) current.count += Number(item.count) || 0
else totals.set(key, { ...item, count: Number(item.count) || 0 })
})
return [...totals.values()].sort((a, b) => b.count - a.count)
}
const providerChartData = computed(() => aggregateChartData(overview.value?.providers || []))
const directionChartData = computed(() => aggregateChartData(overview.value?.directions || []))
const resultChartData = computed(() => aggregateChartData(overview.value?.results || []))
const syncDates = () => {
query.created_from = dateRange.value?.[0] || undefined
query.created_to = dateRange.value?.[1] || undefined
}
const loadList = async () => {
syncDates()
const data = (await AuditService.getIntegrations(query)).data
items.value = data.items
total.value = data.total
}
const load = async () => {
loading.value = true
try {
syncDates()
overview.value = (await AuditService.getIntegrationOverview({ ...query, bucket: 'day' })).data
await loadList()
} catch {
ElMessage.error('外部交互调查加载失败')
} finally {
loading.value = false
}
}
const search = () => {
query.page = 1
load()
}
const apply = (field: 'provider' | 'direction' | 'result', code: string) => {
;(query as Record<string, unknown>)[field] = code
search()
}
const categoryMeta = (row: IntegrationListItem) =>
integrationCategoryMeta[row.result_category] || {
label: row.result_category,
type: 'info' as const
}
onMounted(load)
</script>
<style scoped>
.integration-scroll-container {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.integration-page {
box-sizing: border-box;
flex: none !important;
height: auto !important;
min-height: 100%;
padding: 20px;
}
.filter-card,
.metric-grid,
.overview-grid {
margin-bottom: 16px;
}
.filter-grid {
row-gap: 16px;
}
.filter-card :deep(.el-form-item) {
width: 100%;
margin-right: 0;
margin-bottom: 0;
}
.filter-card :deep(.el-form-item__content) {
min-width: 0;
}
.filter-card :deep(.el-select),
.filter-card :deep(.el-date-editor),
.filter-card :deep(.el-button) {
width: 100% !important;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
}
.metric-grid span {
display: block;
color: var(--el-text-color-secondary);
}
.metric-content {
display: flex;
gap: 14px;
align-items: center;
}
.metric-icon {
display: inline-flex !important;
flex: 0 0 42px;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
font-size: 20px;
border-radius: 10px;
}
.metric-icon--primary {
color: var(--el-color-primary) !important;
background: var(--el-color-primary-light-9);
}
.metric-icon--danger {
color: var(--el-color-danger) !important;
background: var(--el-color-danger-light-9);
}
.metric-icon--success {
color: var(--el-color-success) !important;
background: var(--el-color-success-light-9);
}
.metric-icon--warning {
color: var(--el-color-warning) !important;
background: var(--el-color-warning-light-9);
}
.metric-icon--info {
color: var(--el-color-info) !important;
background: var(--el-color-info-light-9);
}
.metric-grid strong {
display: block;
margin-top: 8px;
font-size: 24px;
}
.danger {
color: var(--el-color-danger);
}
.overview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.section-title {
display: inline-flex;
gap: 7px;
align-items: center;
font-weight: 600;
color: var(--el-text-color-primary);
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
@media (width <= 1100px) {
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
.overview-grid {
grid-template-columns: 1fr;
}
}
@media (width >= 1200px) {
.filter-col {
flex: 0 0 20%;
max-width: 20%;
}
}
@media (width <= 520px) {
.integration-page {
padding: 12px;
}
.metric-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,405 @@
<template>
<ArtTableFullScreen class="risk-scroll-container">
<div class="risk-page" id="table-full-screen">
<ElCard shadow="never" class="filter-card">
<ElForm>
<ElRow :gutter="16" class="filter-grid">
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElDatePicker
v-model="dateRange"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DDTHH:mm:ssZ"
/>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElSelect v-model="query.risk" clearable placeholder="请选择风险">
<ElOption
v-for="item in overview?.risks || []"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElSelect v-model="query.result" clearable placeholder="请选择结果">
<ElOption
v-for="item in overview?.results || []"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem>
<ElSelect v-model="query.action" filterable clearable placeholder="请选择动作">
<ElOption
v-for="item in overview?.actions || []"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12" :md="8" class="filter-col">
<ElFormItem class="query-form-item">
<ElButton type="primary" :icon="Search" @click="search">查询</ElButton>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</ElCard>
<div v-if="overview" class="metric-grid"
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--primary"
><ElIcon><DataAnalysis /></ElIcon></span
><div
><span>事件总数</span><strong>{{ overview.total }}</strong></div
></div
></ElCard
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--danger"
><ElIcon><WarningFilled /></ElIcon></span
><div
><span>高风险信号</span
><strong class="danger">{{ signalCount('high_risk') }}</strong></div
></div
></ElCard
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--warning"
><ElIcon><Money /></ElIcon></span
><div
><span>资金信号</span><strong>{{ signalCount('finance') }}</strong></div
></div
></ElCard
><ElCard shadow="never" class="metric-card"
><div class="metric-content"
><span class="metric-icon metric-icon--success"
><ElIcon><Lock /></ElIcon></span
><div
><span>安全信号</span><strong>{{ signalCount('security') }}</strong></div
></div
></ElCard
></div
>
<div v-if="overview" class="overview-grid"
><ElCard shadow="never"
><template #header
><div class="section-title"
><ElIcon><WarningFilled /></ElIcon><span>风险分布</span></div
></template
><AuditDistributionChart
title="风险分布"
:data="riskChartData"
@select="apply('risk', $event)" /></ElCard
><ElCard shadow="never"
><template #header
><div class="section-title"
><ElIcon><CircleCheckFilled /></ElIcon><span>结果分布</span></div
></template
><AuditDistributionChart
title="结果分布"
:data="resultChartData"
@select="apply('result', $event)" /></ElCard
><ElCard shadow="never"
><template #header
><div class="section-title"
><ElIcon><Connection /></ElIcon><span>来源分布</span></div
></template
><AuditDistributionChart
title="来源分布"
type="bar"
:data="sourceChartData"
@select="apply('source', $event)" /></ElCard
></div>
<ElCard shadow="never" class="art-table-card" v-loading="loading"
><ArtTableHeader v-model:columns="headerColumns" @refresh="load"
><template #left
><div class="card-title"
><span class="section-title"
><ElIcon><DataAnalysis /></ElIcon><span>风险事件明细</span></span
></div
></template
></ArtTableHeader
><div class="risk-event-table">
<AuditEventTable
:items="events"
@detail="(row) => router.push(`/audit/events/${encodeURIComponent(row.event_id)}`)"
/>
</div>
<div class="pagination"
><ElPagination
v-model:current-page="query.page"
v-model:page-size="query.page_size"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@change="loadEvents" /></div
></ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
CircleCheckFilled,
Connection,
DataAnalysis,
Lock,
Money,
Search,
WarningFilled
} from '@element-plus/icons-vue'
import { AuditService } from '@/api/modules'
import type {
AuditEventView,
AuditNamedCount,
AuditRiskEventQuery,
AuditRiskOverview
} from '@/types/api'
import AuditEventTable from '@/components/business/audit/AuditEventTable.vue'
import AuditDistributionChart from '@/components/business/audit/AuditDistributionChart.vue'
defineOptions({ name: 'AuditRisks' })
const router = useRouter()
const loading = ref(false)
const headerColumns = ref([])
const dateRange = ref<string[]>([])
const overview = ref<AuditRiskOverview>()
const events = ref<AuditEventView[]>([])
const total = ref(0)
const query = reactive<AuditRiskEventQuery>({ page: 1, page_size: 20 })
const aggregateChartData = (items: AuditNamedCount[]) => {
const totals = new Map<string, AuditNamedCount>()
items.forEach((item) => {
const key = item.name || item.code
const current = totals.get(key)
if (current) current.count += Number(item.count) || 0
else totals.set(key, { ...item, count: Number(item.count) || 0 })
})
return [...totals.values()].sort((a, b) => b.count - a.count)
}
const riskChartData = computed(() => aggregateChartData(overview.value?.risks || []))
const resultChartData = computed(() => aggregateChartData(overview.value?.results || []))
const sourceChartData = computed(() => aggregateChartData(overview.value?.sources || []))
const syncDates = () => {
query.created_from = dateRange.value?.[0] || undefined
query.created_to = dateRange.value?.[1] || undefined
if (
dateRange.value?.length === 2 &&
new Date(dateRange.value[1]).getTime() - new Date(dateRange.value[0]).getTime() >
31 * 86400000
) {
ElMessage.warning('风险查询时间范围最长为 31 天')
return false
}
return true
}
const loadEvents = async () => {
if (!syncDates()) return
const data = (await AuditService.getRiskEvents(query)).data
events.value = data.items
total.value = data.total
}
const load = async () => {
if (!syncDates()) return
loading.value = true
try {
overview.value = (await AuditService.getRiskOverview(query)).data
await loadEvents()
} catch {
ElMessage.error('风险调查数据加载失败')
} finally {
loading.value = false
}
}
const search = () => {
query.page = 1
load()
}
const signalCount = (code: string) =>
overview.value?.signals.find((item) => item.code === code)?.count || 0
const apply = (field: 'risk' | 'result' | 'source', code: string) => {
;(query as Record<string, unknown>)[field] = code
search()
}
onMounted(load)
</script>
<style scoped>
.risk-scroll-container {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.risk-page {
box-sizing: border-box;
flex: none !important;
height: auto !important;
min-height: 100%;
padding: 20px;
}
.card-title {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.filter-card,
.metric-grid,
.overview-grid {
margin-bottom: 16px;
}
.filter-grid {
row-gap: 16px;
}
.filter-card :deep(.el-form-item) {
width: 100%;
margin-right: 0;
margin-bottom: 0;
}
.filter-card :deep(.el-form-item__content) {
min-width: 0;
}
.filter-card :deep(.el-select),
.filter-card :deep(.el-date-editor) {
width: 100% !important;
}
.query-form-item :deep(.el-button) {
width: 100%;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.metric-grid span {
display: block;
color: var(--el-text-color-secondary);
}
.metric-content {
display: flex;
gap: 14px;
align-items: center;
}
.metric-icon {
display: inline-flex !important;
flex: 0 0 42px;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
font-size: 20px;
border-radius: 10px;
}
.metric-icon--primary {
color: var(--el-color-primary) !important;
background: var(--el-color-primary-light-9);
}
.metric-icon--danger {
color: var(--el-color-danger) !important;
background: var(--el-color-danger-light-9);
}
.metric-icon--warning {
color: var(--el-color-warning) !important;
background: var(--el-color-warning-light-9);
}
.metric-icon--success {
color: var(--el-color-success) !important;
background: var(--el-color-success-light-9);
}
.metric-grid strong {
display: block;
margin-top: 8px;
font-size: 28px;
}
.danger {
color: var(--el-color-danger);
}
.overview-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.section-title {
display: inline-flex;
gap: 7px;
align-items: center;
font-weight: 600;
color: var(--el-text-color-primary);
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.risk-event-table {
margin-top: 16px;
}
@media (width <= 900px) {
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
.overview-grid {
grid-template-columns: 1fr;
}
}
@media (width >= 1200px) {
.filter-col {
flex: 0 0 20%;
max-width: 20%;
}
}
@media (width <= 520px) {
.risk-page {
padding: 12px;
}
.metric-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -30,6 +30,8 @@
:currentPage="pagination.page" :currentPage="pagination.page"
:pageSize="pagination.pageSize" :pageSize="pagination.pageSize"
:total="pagination.total" :total="pagination.total"
:actions="getAuditActions"
:actionsWidth="120"
:marginTop="10" :marginTop="10"
@size-change="handleSizeChange" @size-change="handleSizeChange"
@current-change="handleCurrentChange" @current-change="handleCurrentChange"
@@ -458,6 +460,9 @@
} from '@/config/constants/commission' } from '@/config/constants/commission'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue' import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import { resolveFinanceAuditTarget } from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
defineOptions({ name: 'AgentCommission' }) defineOptions({ name: 'AgentCommission' })
@@ -471,6 +476,20 @@
const tableRef = ref() const tableRef = ref()
const summaryList = ref<ShopFundSummaryItem[]>([]) const summaryList = ref<ShopFundSummaryItem[]>([])
const getAuditActions = (row: ShopFundSummaryItem) => {
if (!hasAuth(AUDIT_PERMISSIONS.financeTimeline)) return []
const target = resolveFinanceAuditTarget('shop_id', row.shop_id)
return target
? [
{
label: '资金链路',
handler: () => openAuditInvestigation(target),
type: 'primary' as const
}
]
: []
}
// 搜索表单 // 搜索表单
const searchForm = reactive({ const searchForm = reactive({
shop_name: '', shop_name: '',

View File

@@ -10,6 +10,34 @@
返回 返回
</ElButton> </ElButton>
<h2 class="detail-title">{{ pageTitle }}</h2> <h2 class="detail-title">{{ pageTitle }}</h2>
<ElButton
v-if="detailData && isPlatformUser && hasAuth(AUDIT_PERMISSIONS.resourceTimeline)"
type="primary"
plain
@click="openRechargeAudit"
>
审计记录
</ElButton>
<ElButton
v-if="detailData && isPlatformUser && hasAuth(AUDIT_PERMISSIONS.financeTimeline)"
type="primary"
plain
@click="openRechargeFinance"
>
资金链路
</ElButton>
<ElButton
v-if="
detailData?.approval_instance_id &&
isPlatformUser &&
hasAuth(AUDIT_PERMISSIONS.resourceTimeline)
"
type="primary"
plain
@click="openApprovalAudit"
>
审批审计
</ElButton>
</div> </div>
<!-- 详情内容 --> <!-- 详情内容 -->
@@ -42,15 +70,44 @@
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business' import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import { formatRejectionReason } from './agentRechargeDisplay' import { formatRejectionReason } from './agentRechargeDisplay'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import {
resolveAuditResourceTarget,
resolveFinanceAuditTarget
} from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
defineOptions({ name: 'AgentRechargeDetail' }) defineOptions({ name: 'AgentRechargeDetail' })
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { hasAuth } = useAuth()
const userStore = useUserStore()
const loading = ref(false) const loading = ref(false)
const detailData = ref<AgentRecharge | null>(null) const detailData = ref<AgentRecharge | null>(null)
const paymentVoucherFileKeys = ref<string[]>([]) const paymentVoucherFileKeys = ref<string[]>([])
const isPlatformUser = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
const openRechargeAudit = () => {
const target = resolveAuditResourceTarget({
resourceType: 'agent_recharge',
internalId: detailData.value?.id
})
if (target) openAuditInvestigation(target)
}
const openRechargeFinance = () => {
const target = resolveFinanceAuditTarget('recharge_id', detailData.value?.id)
if (target) openAuditInvestigation(target)
}
const openApprovalAudit = () => {
const target = resolveAuditResourceTarget({
resourceType: 'approval_instance',
internalId: detailData.value?.approval_instance_id
})
if (target) openAuditInvestigation(target)
}
const pageTitle = computed(() => `充值订单详情`) const pageTitle = computed(() => `充值订单详情`)

View File

@@ -41,8 +41,8 @@
:total="pagination.total" :total="pagination.total"
:marginTop="10" :marginTop="10"
:actions="getActions" :actions="getActions"
:inlineActionsCount="2" :inlineActionsCount="1"
:actionsWidth="200" :actionsWidth="160"
@size-change="handleSizeChange" @size-change="handleSizeChange"
@current-change="handleCurrentChange" @current-change="handleCurrentChange"
> >
@@ -317,6 +317,12 @@
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business' import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import {
resolveAuditResourceTarget,
resolveFinanceAuditTarget
} from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue' import VoucherUpload from '@/components/business/VoucherUpload.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue' import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
@@ -1284,12 +1290,46 @@
// 获取操作按钮 // 获取操作按钮
const getActions = (row: AgentRecharge) => { const getActions = (row: AgentRecharge) => {
return buildAgentRechargeActions(row, { const actions = buildAgentRechargeActions(row, {
hasAuth, hasAuth,
onViewPaymentVoucher: handleViewPaymentVoucher, onViewPaymentVoucher: handleViewPaymentVoucher,
onConfirmPayment: handleShowConfirmPay, onConfirmPayment: handleShowConfirmPay,
onReject: handleShowReject onReject: handleShowReject
}) })
if (isPlatformAccount.value && hasAuth(AUDIT_PERMISSIONS.resourceTimeline)) {
const resourceTarget = resolveAuditResourceTarget({
resourceType: 'agent_recharge',
internalId: row.id
})
if (resourceTarget)
actions.unshift({
label: '审计记录',
handler: () => openAuditInvestigation(resourceTarget),
type: 'primary'
})
}
if (isPlatformAccount.value && hasAuth(AUDIT_PERMISSIONS.financeTimeline)) {
const financeTarget = resolveFinanceAuditTarget('recharge_id', row.id)
if (financeTarget)
actions.unshift({
label: '资金链路',
handler: () => openAuditInvestigation(financeTarget),
type: 'primary'
})
}
if (isPlatformAccount.value && hasAuth(AUDIT_PERMISSIONS.resourceTimeline)) {
const approvalTarget = resolveAuditResourceTarget({
resourceType: 'approval_instance',
internalId: row.approval_instance_id
})
if (approvalTarget)
actions.unshift({
label: '审批审计',
handler: () => openAuditInvestigation(approvalTarget),
type: 'primary'
})
}
return actions
} }
// 查看支付凭证 // 查看支付凭证

View File

@@ -10,6 +10,34 @@
返回 返回
</ElButton> </ElButton>
<h2 class="detail-title">{{ pageTitle }}</h2> <h2 class="detail-title">{{ pageTitle }}</h2>
<ElButton
v-if="refund && isPlatformUser && hasAuth(AUDIT_PERMISSIONS.refundEntry)"
type="primary"
plain
@click="openRefundAudit"
>
审计记录
</ElButton>
<ElButton
v-if="refund && isPlatformUser && hasAuth(AUDIT_PERMISSIONS.financeTimeline)"
type="primary"
plain
@click="openRefundFinance"
>
资金链路
</ElButton>
<ElButton
v-if="
refund?.approval_instance_id &&
isPlatformUser &&
hasAuth(AUDIT_PERMISSIONS.resourceTimeline)
"
type="primary"
plain
@click="openApprovalAudit"
>
审批审计
</ElButton>
<ElButton <ElButton
v-if="refund && canResubmit(refund) && hasAuth('refund:resubmit')" v-if="refund && canResubmit(refund) && hasAuth('refund:resubmit')"
type="primary" type="primary"
@@ -119,16 +147,43 @@
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue' import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import {
resolveAuditResourceTarget,
resolveFinanceAuditTarget
} from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
defineOptions({ name: 'RefundDetail' }) defineOptions({ name: 'RefundDetail' })
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { hasAuth } = useAuth() const { hasAuth } = useAuth()
const userStore = useUserStore()
const loading = ref(false) const loading = ref(false)
const refund = ref<Refund | null>(null) const refund = ref<Refund | null>(null)
const refundVoucherFileKeys = ref<string[]>([]) const refundVoucherFileKeys = ref<string[]>([])
const isPlatformUser = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
const openRefundAudit = () => {
const target = resolveAuditResourceTarget({
resourceType: 'refund',
internalId: refund.value?.id
})
if (target) openAuditInvestigation(target)
}
const openRefundFinance = () => {
const target = resolveFinanceAuditTarget('refund_id', refund.value?.id)
if (target) openAuditInvestigation(target)
}
const openApprovalAudit = () => {
const target = resolveAuditResourceTarget({
resourceType: 'approval_instance',
internalId: refund.value?.approval_instance_id
})
if (target) openAuditInvestigation(target)
}
const pageTitle = computed(() => `退款详情`) const pageTitle = computed(() => `退款详情`)

View File

@@ -38,8 +38,8 @@
:total="pagination.total" :total="pagination.total"
:marginTop="10" :marginTop="10"
:actions="getActions" :actions="getActions"
:actionsWidth="220" :actionsWidth="160"
:inlineActionsCount="2" :inlineActionsCount="1"
@size-change="handleSizeChange" @size-change="handleSizeChange"
@current-change="handleCurrentChange" @current-change="handleCurrentChange"
> >
@@ -164,6 +164,13 @@
} from '@/utils/business/approvalSummary' } from '@/utils/business/approvalSummary'
import { toVoucherKeyList } from '@/utils/business' import { toVoucherKeyList } from '@/utils/business'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import {
resolveAuditResourceTarget,
resolveFinanceAuditTarget
} from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import { useUserStore } from '@/store/modules/user'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue' import VoucherUpload from '@/components/business/VoucherUpload.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue' import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
@@ -174,6 +181,7 @@
defineOptions({ name: 'RefundList' }) defineOptions({ name: 'RefundList' })
const router = useRouter() const router = useRouter()
const userStore = useUserStore()
const { hasAuth } = useAuth() const { hasAuth } = useAuth()
const loading = ref(false) const loading = ref(false)
@@ -730,6 +738,43 @@
// 获取操作按钮 // 获取操作按钮
const getActions = (row: Refund) => { const getActions = (row: Refund) => {
const actions: any[] = [] const actions: any[] = []
if (
[1, 2].includes(userStore.getUserInfo.user_type || 0) &&
hasAuth(AUDIT_PERMISSIONS.refundEntry)
) {
const auditTarget = resolveAuditResourceTarget({
userType: userStore.getUserInfo.user_type,
resourceType: 'refund',
internalId: row.id
})
if (auditTarget)
actions.push({
label: '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
if (hasAuth(AUDIT_PERMISSIONS.financeTimeline)) {
const financeTarget = resolveFinanceAuditTarget('refund_id', row.id)
if (financeTarget)
actions.push({
label: '资金链路',
handler: () => openAuditInvestigation(financeTarget),
type: 'primary'
})
}
const approvalTarget = resolveAuditResourceTarget({
userType: userStore.getUserInfo.user_type,
resourceType: 'approval_instance',
internalId: row.approval_instance_id
})
if (approvalTarget)
actions.push({
label: '审批审计',
handler: () => openAuditInvestigation(approvalTarget),
type: 'primary'
})
}
const voucherKeys = getRefundAttachmentKeys(row) const voucherKeys = getRefundAttachmentKeys(row)
if (canResubmit(row) && hasAuth('refund:resubmit')) { if (canResubmit(row) && hasAuth('refund:resubmit')) {

View File

@@ -10,6 +10,22 @@
返回 返回
</ElButton> </ElButton>
<h2 class="detail-title">订单详情</h2> <h2 class="detail-title">订单详情</h2>
<ElButton
v-if="detailData && isPlatformUser && hasAuth(AUDIT_PERMISSIONS.orderEntry)"
type="primary"
plain
@click="openOrderAudit"
>
审计记录
</ElButton>
<ElButton
v-if="detailData && isPlatformUser && hasAuth(AUDIT_PERMISSIONS.financeTimeline)"
type="primary"
plain
@click="openOrderFinance"
>
资金链路
</ElButton>
</div> </div>
<!-- 详情内容 --> <!-- 详情内容 -->
@@ -82,6 +98,12 @@
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business' import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import {
resolveAuditResourceTarget,
resolveFinanceAuditTarget
} from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
defineOptions({ name: 'OrderDetail' }) defineOptions({ name: 'OrderDetail' })
@@ -93,6 +115,18 @@
const loading = ref(false) const loading = ref(false)
const detailData = ref<Order | null>(null) const detailData = ref<Order | null>(null)
const paymentVoucherFileKeys = ref<string[]>([]) const paymentVoucherFileKeys = ref<string[]>([])
const isPlatformUser = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
const openOrderAudit = () => {
const target = resolveAuditResourceTarget({
resourceType: 'order',
internalId: detailData.value?.id
})
if (target) openAuditInvestigation(target)
}
const openOrderFinance = () => {
const target = resolveFinanceAuditTarget('order_id', detailData.value?.id)
if (target) openAuditInvestigation(target)
}
// 格式化货币 - 将分转换为元 // 格式化货币 - 将分转换为元
const formatCurrency = (amount: number): string => { const formatCurrency = (amount: number): string => {

View File

@@ -282,6 +282,12 @@
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business' import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import {
resolveAuditResourceTarget,
resolveFinanceAuditTarget
} from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue' import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue' import VoucherUpload from '@/components/business/VoucherUpload.vue'
@@ -1351,6 +1357,31 @@
const getActions = (row: Order) => { const getActions = (row: Order) => {
const actions: any[] = [] const actions: any[] = []
if (
[1, 2].includes(userStore.getUserInfo.user_type || 0) &&
hasAuth(AUDIT_PERMISSIONS.orderEntry)
) {
const auditTarget = resolveAuditResourceTarget({
userType: userStore.getUserInfo.user_type,
resourceType: 'order',
internalId: row.id
})
if (auditTarget)
actions.push({
label: '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
if (hasAuth(AUDIT_PERMISSIONS.financeTimeline)) {
const financeTarget = resolveFinanceAuditTarget('order_id', row.id)
if (financeTarget)
actions.push({
label: '资金链路',
handler: () => openAuditInvestigation(financeTarget),
type: 'primary'
})
}
}
// 线下支付且有凭证可以查看 // 线下支付且有凭证可以查看
if ( if (
row.payment_method === 'offline' && row.payment_method === 'offline' &&

View File

@@ -351,6 +351,12 @@
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants' import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
import { regionData } from '@/utils/constants/regionData' import { regionData } from '@/utils/constants/regionData'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
import {
resolveAuditResourceTarget,
resolveFinanceAuditTarget
} from '@/utils/business/auditNavigation'
import { openAuditInvestigation } from '@/components/business/audit/investigationController'
defineOptions({ name: 'Shop' }) defineOptions({ name: 'Shop' })
@@ -809,6 +815,32 @@
const getActions = (row: ShopResponse) => { const getActions = (row: ShopResponse) => {
const actions: any[] = [] const actions: any[] = []
if (hasAuth(AUDIT_PERMISSIONS.shopEntry)) {
const auditTarget = resolveAuditResourceTarget({
userType: userStore.getUserInfo.user_type,
resourceType: 'shop',
internalId: row.id,
businessIdentifier: row.shop_code
})
if (auditTarget)
actions.push({
label: '审计记录',
handler: () => openAuditInvestigation(auditTarget),
type: 'primary'
})
}
if (
[1, 2].includes(userStore.getUserInfo.user_type || 0) &&
hasAuth(AUDIT_PERMISSIONS.financeTimeline)
) {
const financeTarget = resolveFinanceAuditTarget('shop_id', row.id)
if (financeTarget)
actions.push({
label: '资金链路',
handler: () => openAuditInvestigation(financeTarget),
type: 'primary'
})
}
if (hasAuth('shop:look_customer')) { if (hasAuth('shop:look_customer')) {
actions.push({ actions.push({
label: '账号列表', label: '账号列表',