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

@@ -335,6 +335,9 @@
JULY_PERMISSIONS,
STATUS_SELECT_OPTIONS
} 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 缓存控制
@@ -670,6 +673,19 @@
const getActions = (row: 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')) {
actions.push({
label: '分配角色',

View File

@@ -204,6 +204,9 @@
import { h, onActivated } from 'vue'
import { useRouter } from 'vue-router'
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 { ElMessage, ElSwitch, ElCascader } from 'element-plus'
import type { CascaderOption, CascaderValue, FormInstance, FormRules } from 'element-plus'
@@ -502,6 +505,21 @@
const getActions = (row: EnterpriseItem) => {
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')) {
actions.push({
label: '账号列表',

View File

@@ -9,6 +9,9 @@
</ElTag>
</div>
<div class="card-header-right">
<ElButton v-if="auditTarget" type="primary" link @click="openAssetAudit">
{{ isSubjectActivityView ? '活动记录' : '审计记录' }}
</ElButton>
<ElButton
v-if="
cardInfo?.asset_type === 'card' &&
@@ -311,7 +314,7 @@
{{ scope.row.gateway_extend || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="180" fixed="right">
<ElTableColumn label="操作" width="280" fixed="right">
<template #default="scope">
<!-- 根据网络状态显示启用或停用按钮 -->
<ElButton
@@ -345,6 +348,24 @@
>
更新实名状态
</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>
</ElTableColumn>
</ElTable>
@@ -589,10 +610,14 @@
getExpiryEstimateTooltip,
getExpiryEstimateClass
} 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
interface BindingCard {
id: number
card_id?: number
iccid: string
msisdn?: string
carrier_name?: string
@@ -609,7 +634,9 @@
}
interface AssetInfo {
asset_id: number
asset_type: 'card' | 'device'
identifier: string
iccid?: string
imei?: string
msisdn?: string
@@ -717,6 +744,64 @@
const { hasAuth } = useAuth()
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 CARD_MONTH_USAGE_PERMISSION = 'asset_info:view_card_month_usage'

View File

@@ -44,6 +44,20 @@
/>
<ElButton type="primary" @click="handleQuery">查询</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>
</template>
@@ -167,6 +181,13 @@
AssetWalletTransactionParams,
TransactionType
} 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 {
assetIdentifier: string
@@ -182,6 +203,19 @@
boundDeviceId: undefined,
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<{
(e: 'navigateToDevice', deviceNo: string): void

View File

@@ -969,6 +969,10 @@
import { computed, h } from 'vue'
import { useRouter } from 'vue-router'
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 {
DeviceService,
ShopService,
@@ -1027,6 +1031,7 @@
const { hasAuth } = useAuth()
const router = useRouter()
const userStore = useUserStore()
const loading = 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 中
if (moreActions.length > 0) {
actions.push(...moreActions)

View File

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

View File

@@ -35,7 +35,7 @@
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="120"
:actionsWidth="160"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -438,10 +438,15 @@
getProcessingStatusText
} from '@/utils/business/approvalSummary'
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' })
const { hasAuth } = useAuth()
const userStore = useUserStore()
const router = useRouter()
const currentExchangeRow = ref<ExchangeResponse | null>(null)
@@ -1548,6 +1553,45 @@
const actions: any[] = []
const status = row.status
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') {

View File

@@ -1024,6 +1024,9 @@
import { computed, h } from 'vue'
import { useRouter } from 'vue-router'
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 {
CardService,
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
}
@@ -2448,8 +2472,8 @@
return
}
const successCount = res.data?.success_count ?? assetIds.length
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
const successCount = res.data?.success_count ?? assetIds.length
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
batchRealnamePolicyDialogVisible.value = false
selectedCards.value = []
await getTableData()

View File

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

View File

@@ -28,6 +28,8 @@
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="180"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -51,10 +53,15 @@
import { formatDateTime } from '@/utils/business/format'
import type { AssetAllocationRecord, AllocationTypeEnum, AssetTypeEnum } from '@/types/api/card'
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' })
const { hasAuth } = useAuth()
const userStore = useUserStore()
const router = useRouter()
const loading = ref(false)
@@ -221,6 +228,40 @@
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) => {
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"
:pageSize="pagination.pageSize"
:total="pagination.total"
:actions="getAuditActions"
:actionsWidth="120"
:marginTop="10"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@@ -458,6 +460,9 @@
} from '@/config/constants/commission'
import { RoutesAlias } from '@/router/routesAlias'
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' })
@@ -471,6 +476,20 @@
const tableRef = ref()
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({
shop_name: '',

View File

@@ -10,6 +10,34 @@
返回
</ElButton>
<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>
<!-- 详情内容 -->
@@ -42,15 +70,44 @@
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
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' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const userStore = useUserStore()
const loading = ref(false)
const detailData = ref<AgentRecharge | null>(null)
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(() => `充值订单详情`)

View File

@@ -41,8 +41,8 @@
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:inlineActionsCount="2"
:actionsWidth="200"
:inlineActionsCount="1"
:actionsWidth="160"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -317,6 +317,12 @@
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
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'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
@@ -1284,12 +1290,46 @@
// 获取操作按钮
const getActions = (row: AgentRecharge) => {
return buildAgentRechargeActions(row, {
const actions = buildAgentRechargeActions(row, {
hasAuth,
onViewPaymentVoucher: handleViewPaymentVoucher,
onConfirmPayment: handleShowConfirmPay,
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>
<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
v-if="refund && canResubmit(refund) && hasAuth('refund:resubmit')"
type="primary"
@@ -119,16 +147,43 @@
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
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' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const userStore = useUserStore()
const loading = ref(false)
const refund = ref<Refund | null>(null)
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(() => `退款详情`)

View File

@@ -38,8 +38,8 @@
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="220"
:inlineActionsCount="2"
:actionsWidth="160"
:inlineActionsCount="1"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -164,6 +164,13 @@
} from '@/utils/business/approvalSummary'
import { toVoucherKeyList } from '@/utils/business'
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 VoucherUpload from '@/components/business/VoucherUpload.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
@@ -174,6 +181,7 @@
defineOptions({ name: 'RefundList' })
const router = useRouter()
const userStore = useUserStore()
const { hasAuth } = useAuth()
const loading = ref(false)
@@ -730,6 +738,43 @@
// 获取操作按钮
const getActions = (row: Refund) => {
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)
if (canResubmit(row) && hasAuth('refund:resubmit')) {

View File

@@ -10,6 +10,22 @@
返回
</ElButton>
<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>
<!-- 详情内容 -->
@@ -82,6 +98,12 @@
import { formatDateTime } from '@/utils/business/format'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
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' })
@@ -93,6 +115,18 @@
const loading = ref(false)
const detailData = ref<Order | null>(null)
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 => {

View File

@@ -282,6 +282,12 @@
import { formatDateTime } from '@/utils/business/format'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
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 ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
@@ -1351,6 +1357,31 @@
const getActions = (row: Order) => {
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 (
row.payment_method === 'offline' &&

View File

@@ -351,6 +351,12 @@
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
import { regionData } from '@/utils/constants/regionData'
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' })
@@ -809,6 +815,32 @@
const getActions = (row: ShopResponse) => {
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')) {
actions.push({
label: '账号列表',