2026-08-13 11:57:44 +08:00
< script setup lang = "ts" >
import { ref , watch } from 'vue'
2026-09-14 17:06:55 +08:00
import { CircleCheck , CircleX , ScrollText } from '@lucide/vue'
2026-08-13 11:57:44 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getPlatformAuditLog , listPlatformAuditLogs , type PlatformAuditLogDetail , type PlatformAuditLogListItem , type PlatformAuditLogQuery } from '../api/platformAudit'
2026-08-18 09:46:34 +08:00
import { auditDescriptionLabel , auditEventLabel , auditHttpMethodLabel , auditOperatorLabel , auditResourceLabel , auditServiceLabel } from '../presentation'
2026-08-13 11:57:44 +08:00
const props = defineProps < { customerId : string ; customerName : string } > ()
const access = useAccessPreviewStore ()
const rows = ref < PlatformAuditLogListItem [] >([])
const page = ref ( 1 )
const pageSize = ref ( 20 )
const total = ref ( 0 )
const loading = ref ( false )
const loadError = ref < ApiErrorDetails | null >( null )
const detailOpen = ref ( false )
const detailLoading = ref ( false )
const detail = ref < PlatformAuditLogDetail | null >( null )
const detailError = ref < ApiErrorDetails | null >( null )
const emptyFilters = () : QueryModel => ({ keyword : '' , success : '' , occurredRange : [] })
const filters = ref < QueryModel >( emptyFilters ())
const appliedFilters = ref < QueryModel >( emptyFilters ())
let requestSerial = 0
const queryFields : QueryField [] = [
{ key : 'keyword' , type : 'input' , label : '关键词' , width : 260 , placeholder : '操作内容/操作人/资源或请求标识' },
{ key : 'success' , type : 'select' , label : '结果' , width : 150 , options : [{ label : '成功' , value : true }, { label : '失败' , value : false }] },
{ key : 'occurredRange' , type : 'daterange' , label : '发生日期' , width : 280 },
]
const columns : DataTableColumn [] = [
{ key : 'actions' , label : '操作' , width : 92 , fixed : 'left' , slotName : 'actions' },
2026-08-18 09:46:34 +08:00
{ key : 'description' , label : '操作内容' , minWidth : 220 , slotName : 'description' },
2026-08-14 16:06:30 +08:00
{ key : 'clientIp' , label : 'IP 地址' , width : 140 , slotName : 'clientIp' },
2026-08-18 09:46:34 +08:00
{ key : 'eventType' , label : '事件类型' , minWidth : 180 , slotName : 'eventType' },
{ key : 'username' , label : '操作人' , width : 130 , slotName : 'username' },
{ key : 'serviceName' , label : '来源服务' , width : 145 , slotName : 'serviceName' },
2026-08-13 11:57:44 +08:00
{ key : 'success' , label : '结果' , width : 90 , slotName : 'success' },
{ key : 'occurredAt' , label : '发生时间' , width : 170 , slotName : 'occurredAt' },
]
const display = ( value ?: string | null ) => value ? . trim () || '—'
const formatDate = ( value ?: string | null ) => value ? new Date ( value ). toLocaleString ( 'zh-CN' , { hour12 : false }) : '—'
function formatSnapshot ( value ?: string | null ) {
if ( ! value ? . trim ()) return '无'
try { return JSON . stringify ( JSON . parse ( value ), null , 2 ) }
catch { return value }
}
function localDayIso ( value : string , end : boolean ) {
const [ year , month , day ] = value . split ( '-' ). map ( Number )
if ( ! year || ! month || ! day ) return undefined
return new Date ( year , month - 1 , day , end ? 23 : 0 , end ? 59 : 0 , end ? 59 : 0 , end ? 999 : 0 ). toISOString ()
}
function toQuery ( customerId : string ) : PlatformAuditLogQuery {
const range = Array . isArray ( appliedFilters . value . occurredRange ) ? appliedFilters . value . occurredRange . map ( String ) : []
const success = appliedFilters . value . success
return {
accountId : customerId ,
page : page . value ,
size : pageSize . value ,
keyword : String ( appliedFilters . value . keyword || '' ). trim () || undefined ,
success : typeof success === 'boolean' ? success : undefined ,
occurredFrom : range [ 0 ] ? localDayIso ( range [ 0 ], false ) : undefined ,
occurredTo : range [ 1 ] ? localDayIso ( range [ 1 ], true ) : undefined ,
}
}
async function load () {
const customerId = props . customerId . trim ()
const serial = ++ requestSerial
rows . value = []
total . value = 0
loadError . value = null
if ( ! customerId || ! access . can ( 'platform:audit:list' )) return
loading . value = true
try {
const result = await listPlatformAuditLogs ( toQuery ( customerId ))
if ( serial !== requestSerial || customerId !== props . customerId . trim ()) return
rows . value = result . records
total . value = result . total
} catch ( error ) {
if ( serial === requestSerial ) loadError . value = getApiErrorDetails ( error , '客户操作日志加载失败,请稍后重试' )
} finally {
if ( serial === requestSerial ) loading . value = false
}
}
function applyFilters ( model : QueryModel ) {
appliedFilters . value = {
keyword : String ( model . keyword || '' ). trim (),
success : typeof model . success === 'boolean' ? model . success : '' ,
occurredRange : Array . isArray ( model . occurredRange ) ? model . occurredRange . map ( String ) : [],
}
if ( page . value === 1 ) void load ()
else page . value = 1
}
async function openDetail ( row : PlatformAuditLogListItem ) {
detailOpen . value = true
detailLoading . value = true
detail . value = null
detailError . value = null
try { detail . value = await getPlatformAuditLog ( row . id ) }
catch ( error ) { detailError . value = getApiErrorDetails ( error , '操作日志详情加载失败,请稍后重试' ) }
finally { detailLoading . value = false }
}
watch (() => props . customerId , () => {
filters . value = emptyFilters ()
appliedFilters . value = emptyFilters ()
if ( page . value === 1 ) void load ()
else page . value = 1
}, { immediate : true })
watch ([ page , pageSize ], ([ nextPage , nextSize ], [ previousPage , previousSize ]) => {
if ( nextPage !== previousPage || nextSize !== previousSize ) void load ()
})
</ script >
< template >
< section class = "customer-audit-panel" >
< div class = "panel-title" >< div >< h2 > 操作日志 </ h2 >< p >{{ customerName }} 已归属当前客户的档案与业务操作记录 </ p ></ div >< ScrollText :size = "19" /></ div >
< div v-if = "!access.can('platform:audit:list')" class="module-empty"><ScrollText :size="30" />< strong > 暂无操作日志查看权限 </ strong >< p > 请联系管理员开通平台操作日志查看权限 。 </ p ></ div >
< template v-else >
< QueryToolbar v-model = "filters" class="audit-query" ariaLabel="客户操作日志筛选" :fields="queryFields" preference-key="customer-audit-logs-query" @search="applyFilters" />
< LoadErrorState v-if = "loadError" title="客户操作日志加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></ template ></ LoadErrorState >
< DataTable v-else v -model:page = "page" v -model:page-size = "pageSize" :data = "rows" :columns = "columns" :loading = "loading" :total = "total" row -key =" id " aria -label =" 客户操作日志列表 " empty -text =" 当前客户暂无操作日志 " preference -key =" customer -audit-logs " border stripe >
2026-09-14 17:06:55 +08:00
< template # actions = "{ row }" >< el-button link type = "primary" : aria -label ="` 查看操作日志 $ { ( row as PlatformAuditLogListItem ) .id } 详情 `" @click ="openDetail(row as PlatformAuditLogListItem)" > 详情 </ el -button ></ template >
2026-08-18 09:46:34 +08:00
< template # description = "{ row }" >< el-tooltip : content = "auditDescriptionLabel((row as PlatformAuditLogListItem).description)" placement = "top" :show-after = "400" >< span class = "cell-clamp" >{{ auditDescriptionLabel (( row as PlatformAuditLogListItem ). description ) }}</ span ></ el-tooltip ></ template >
2026-08-14 16:06:30 +08:00
< template # clientIp = "{ row }" >{{ display (( row as PlatformAuditLogListItem ). clientIp ) }}</ template >
2026-08-18 09:46:34 +08:00
< template # eventType = "{ row }" >< el-tooltip : content = "(row as PlatformAuditLogListItem).eventType" placement = "top" :show-after = "500" >< span class = "event-label" >{{ auditEventLabel (( row as PlatformAuditLogListItem ). eventType ) }}</ span ></ el-tooltip ></ template >
< template # username = "{ row }" >{{ auditOperatorLabel (( row as PlatformAuditLogListItem ). username ) }}</ template >
< template # serviceName = "{ row }" >< el-tooltip : content = "display((row as PlatformAuditLogListItem).serviceName)" placement = "top" :show-after = "500" >< span class = "service-label" >{{ auditServiceLabel (( row as PlatformAuditLogListItem ). serviceName ) }}</ span ></ el-tooltip ></ template >
2026-08-13 11:57:44 +08:00
< template # success = "{ row }" >< el-tag : type = "(row as PlatformAuditLogListItem).success ? 'success' : 'danger'" >{{ ( row as PlatformAuditLogListItem ). success ? '成功' : '失败' }}</ el-tag ></ template >
< template # occurredAt = "{ row }" >{{ formatDate (( row as PlatformAuditLogListItem ). occurredAt ) }}</ template >
< template # pagination -left > 共 {{ total }} 条客户操作记录 </ template >
</ DataTable >
</ template >
2026-09-14 17:54:21 +08:00
< AppDrawer class = "customer-audit-drawer admin-density-drawer" v-model = "detailOpen" title="操作日志详情" size="min(920px, 96vw)" :loading="detailLoading" :show-footer="false" >
2026-08-13 11:57:44 +08:00
< LoadErrorState v-if = "detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></ template ></ LoadErrorState >
< template v-else-if = "detail" >
2026-08-18 09:46:34 +08:00
< section class = "audit-detail-hero" : class = "detail.success ? 'is-success' : 'is-failed'" >
< span class = "result-icon" aria -hidden =" true " >< CircleCheck v-if = "detail.success" :size="22" /><CircleX v-else :size="22" /></ span >
< div class = "hero-copy" >< span > 事件类型 </ span >< h3 >{{ auditEventLabel ( detail . eventType ) }}</ h3 >< p >{{ auditDescriptionLabel ( detail . description ) }}</ p ></ div >
< el-tag : type = "detail.success ? 'success' : 'danger'" effect = "light" >{{ detail . success ? '操作成功' : '操作失败' }}</ el-tag >
</ section >
< section class = "audit-detail-grid" aria -label =" 操作日志基础信息 " >
< div >< span > 操作人 </ span >< strong >{{ auditOperatorLabel ( detail . username ) }}</ strong ></ div >
< div >< span > 发生时间 </ span >< strong >{{ formatDate ( detail . occurredAt ) }}</ strong ></ div >
< div >< span > 来源服务 </ span >< strong >{{ auditServiceLabel ( detail . serviceName ) }}</ strong >< small >{{ display ( detail . serviceName ) }}</ small ></ div >
< div >< span > 客户端 IP </ span >< strong >{{ display ( detail . clientIp ) }}</ strong ></ div >
< div >< span > 业务资源 </ span >< strong >{{ auditResourceLabel ( detail . resourceType ) }}</ strong >< small >{{ display ( detail . resourceId ) }}</ small ></ div >
< div >< span > 处理耗时 </ span >< strong >{{ detail . durationMs == null ? '—' : ` ${ detail . durationMs } ms` }}</ strong ></ div >
< div class = "wide request-cell" >< span > 请求地址 </ span >< div >< el-tag size = "small" effect = "plain" >{{ auditHttpMethodLabel ( detail . httpMethod ) }}</ el-tag >< code >{{ display ( detail . requestUri ) }}</ code ></ div ></ div >
< div class = "wide identity-cell" >< span > 请求标识 </ span >< strong >{{ display ( detail . requestId ) }}</ strong >< small v-if = "detail.traceId" > 链路标识 : {{ detail.traceId }}</ small ></ div >
</ section >
< el-alert v-if = "!detail.success" class="failure-alert" type="error" :closable="false" show-icon :title="display(detail.errorMessage || detail.errorCode)" />
< div class = "snapshot-grid" >< section >< h3 > 操作前快照 </ h3 >< p > 操作执行前保留的业务数据 </ p >< pre >{{ formatSnapshot ( detail . beforeData ) }}</ pre ></ section >< section >< h3 > 操作结果快照 </ h3 >< p > 操作执行后返回或保存的业务数据 </ p >< pre >{{ formatSnapshot ( detail . afterData ) }}</ pre ></ section ></ div >
2026-08-13 11:57:44 +08:00
</ template >
</ AppDrawer >
</ section >
</ template >
< style scoped >
2026-09-14 17:06:55 +08:00
2026-09-14 17:54:21 +08:00
. customer - audit - panel { min - width : 0 ; }. panel - title { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : var ( -- admin - space - 6 , 16 px ); margin - bottom : var ( -- admin - space - 3 , 8 px ); padding - bottom : var ( -- admin - space - 3 , 8 px ); border - bottom : 1 px solid var ( -- cc - border ); }. panel - title h2 { margin : 0 ; color : # 344054 ; font - size : var ( -- admin - font - section , 14 px ); }. panel - title p { margin : 5 px 0 0 ; color : var ( -- cc - muted ); font - size : var ( -- admin - font - caption , 12 px ); }. panel - title > svg { color : var ( -- cc - primary ); }. audit - query { margin - bottom : var ( -- admin - space - 5 , 12 px ); }. module - empty { display : flex ; min - height : var ( -- admin - empty - height , 64 px ); align - items : center ; justify - content : center ; flex - direction : column ; gap : 7 px ; color : # 98 a2b3 ; text - align : center ; }. module - empty strong { color : # 475467 ; font - size : var ( -- admin - font - body , 13 px ); }. module - empty p { margin : 0 ; color : var ( -- cc - muted ); font - size : var ( -- admin - font - caption , 12 px ); }
. cell - clamp { display :- webkit - box ; overflow : hidden ; - webkit - box - orient : vertical ; - webkit - line - clamp : 2 ; line - height : 1.45 ; overflow - wrap : anywhere ; }. event - label { display : inline - flex ; max - width : 100 % ; padding : 3 px 9 px ; overflow : hidden ; border : 1 px solid # dbeafe ; border - radius : 999 px ; background : # eff6ff ; color : # 1 d4ed8 ; font - size : var ( -- admin - font - caption , 12 px ); line - height : 1.35 ; text - overflow : ellipsis ; white - space : nowrap ; }. service - label { display : block ; overflow : hidden ; color : # 475467 ; text - overflow : ellipsis ; white - space : nowrap ; }
. audit - detail - hero { display : flex ; align - items : flex - start ; gap : 13 px ; padding : var ( -- admin - space - 3 , 8 px ) var ( -- admin - space - 4 , 10 px ); border : 1 px solid # d1fadf ; border - radius : 0 ; background : # f6fef9 ; }. audit - detail - hero . is - failed { border - color : # fee4e2 ; background : # fff7f6 ; }. result - icon { display : grid ; width : 38 px ; height : var ( -- admin - tab - height , 38 px ); flex : 0 0 auto ; place - items : center ; border - radius : 50 % ; background : # dcfae6 ; color : # 079455 ; }. is - failed . result - icon { background : # fee4e2 ; color : # d92d20 ; }. hero - copy { min - width : 0 ; flex : 1 ; }. hero - copy > span { color : # 667085 ; font - size : var ( -- admin - font - caption , 12 px ); }. hero - copy h3 { margin : 3 px 0 var ( -- admin - space - 1 , 4 px ); color : # 101828 ; font - size : var ( -- admin - font - section , 14 px ); }. hero - copy p { margin : 0 ; color : # 475467 ; font - size : var ( -- admin - font - body , 13 px ); line - height : 1.55 ; }
. audit - detail - grid { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr )); margin - top : var ( -- admin - space - 6 , 16 px ); overflow : hidden ; border : 1 px solid # e4e7ec ; border - radius : 0 ; background : var ( -- admin - surface , # fff ); }. audit - detail - grid > div { display : flex ; min - width : 0 ; min - height : var ( -- admin - row - height , 36 px ); flex - direction : column ; justify - content : center ; gap : 5 px ; padding : var ( -- admin - space - 2 , 6 px ) var ( -- admin - space - 4 , 10 px ); border - right : 1 px solid # eaecf0 ; border - bottom : 1 px solid # eaecf0 ; }. audit - detail - grid > div : nth - child ( 3 n ) { border - right : 0 ; }. audit - detail - grid > div . wide { grid - column : 1 /- 1 ; border - right : 0 ; }. audit - detail - grid > div : last - child { border - bottom : 0 ; }. audit - detail - grid span { color : # 667085 ; font - size : var ( -- admin - font - caption , 12 px ); }. audit - detail - grid strong { overflow - wrap : anywhere ; color : # 1 d2939 ; font - size : var ( -- admin - font - section , 14 px ); font - weight : 600 ; }. audit - detail - grid small { overflow - wrap : anywhere ; color : # 98 a2b3 ; font - size : var ( -- admin - font - caption , 12 px ); }. request - cell > div { display : flex ; min - width : 0 ; align - items : center ; gap : 9 px ; }. request - cell code { min - width : 0 ; overflow - wrap : anywhere ; color : # 344054 ; font : 12 px / 1.55 ui - monospace , SFMono - Regular , Consolas , monospace ; }. failure - alert { margin - top : var ( -- admin - space - 6 , 16 px ); }
. snapshot - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : var ( -- admin - space - 6 , 16 px ); margin - top : 18 px ; }. snapshot - grid section { min - width : 0 ; padding : var ( -- admin - space - 4 , 10 px ); border : 1 px solid # e4e7ec ; border - radius : 0 ; background : var ( -- admin - surface , # fff ); }. snapshot - grid h3 { margin : 0 ; color : # 344054 ; font - size : var ( -- admin - font - section , 14 px ); }. snapshot - grid p { margin : var ( -- admin - space - 1 , 4 px ) 0 var ( -- admin - space - 4 , 10 px ); color : # 98 a2b3 ; font - size : var ( -- admin - font - caption , 12 px ); }. snapshot - grid pre { min - height : 160 px ; max - height : 360 px ; margin : 0 ; padding : 14 px ; overflow : auto ; border : 1 px solid # eaecf0 ; border - radius : 8 px ; background : var ( -- admin - label - bg , # f8fafc ); color : # 344054 ; font : 12 px / 1.65 ui - monospace , SFMono - Regular , Consolas , monospace ; white - space : pre - wrap ; overflow - wrap : anywhere ; }
2026-08-18 09:46:34 +08:00
@ media ( max - width : 720 px ){. audit - detail - grid { grid - template - columns : 1 fr }. audit - detail - grid > div ,. audit - detail - grid > div : nth - child ( 3 n ){ border - right : 0 }. audit - detail - grid > div . wide { grid - column : auto }. snapshot - grid { grid - template - columns : 1 fr }. audit - detail - hero { align - items : flex - start }. audit - detail - hero > . el - tag { flex : 0 0 auto }}
2026-08-13 11:57:44 +08:00
</ style >