2026-08-13 11:57:44 +08:00
< script setup lang = "ts" >
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
2026-09-14 18:34:34 +08:00
import '@/styles/adminDensity.css'
2026-10-20 10:11:31 +08:00
import { computed , onBeforeUnmount , ref , watch } from 'vue'
import { 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 )
2026-10-20 10:11:31 +08:00
const selectedLogId = ref < string | null >( null )
const detailTitle = computed (() => props . customerName . trim () ? `操作日志详情 · ${ props . customerName . trim () } ` : '操作日志详情' )
let detailRequestSerial = 0
2026-08-13 11:57:44 +08:00
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
2026-10-20 10:11:31 +08:00
if ( ! customerId || ! access . can ( 'platform:audit:list' )) { loading . value = false ; return }
2026-08-13 11:57:44 +08:00
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
}
2026-10-20 10:11:31 +08:00
function clearDetail () {
detailRequestSerial += 1
selectedLogId . value = null
detailOpen . value = false
detailLoading . value = false
detail . value = null
detailError . value = null
}
async function loadDetail () {
const logId = selectedLogId . value
const customerId = props . customerId . trim ()
if ( ! logId || ! detailOpen . value || ! access . can ( 'platform:audit:list' )) return
const serial = ++ detailRequestSerial
const isCurrent = () => serial === detailRequestSerial && customerId === props . customerId . trim () && detailOpen . value
2026-08-13 11:57:44 +08:00
detailLoading . value = true
detail . value = null
detailError . value = null
2026-10-20 10:11:31 +08:00
try {
const result = await getPlatformAuditLog ( logId )
if ( isCurrent ()) detail . value = result
} catch ( error ) {
if ( isCurrent ()) detailError . value = getApiErrorDetails ( error , '操作日志详情加载失败,请稍后重试' )
} finally {
if ( isCurrent ()) detailLoading . value = false
}
2026-08-13 11:57:44 +08:00
}
2026-10-20 10:11:31 +08:00
async function openDetail ( row : PlatformAuditLogListItem ) {
selectedLogId . value = row . id
detailOpen . value = true
await loadDetail ()
}
watch ( detailOpen , open => { if ( ! open ) clearDetail () })
onBeforeUnmount (() => { requestSerial += 1 ; detailRequestSerial += 1 })
watch ([() => props . customerId , () => access . can ( 'platform:audit:list' )], () => {
clearDetail ()
2026-08-13 11:57:44 +08:00
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 >
2026-10-20 23:34:11 +08:00
< section class = "customer-audit-panel admin-density admin-workbench" >
< div class = "panel-title" >< div >< h2 > 操作日志 </ h2 ></ div >< ScrollText :size = "19" /></ div >
2026-08-13 11:57:44 +08:00
< div v-if = "!access.can('platform:audit:list')" class="module-empty"><ScrollText :size="30" />< strong > 暂无操作日志查看权限 </ strong >< p > 请联系管理员开通平台操作日志查看权限 。 </ p ></ div >
< template v-else >
2026-10-20 23:34:11 +08:00
< QueryToolbar v-model = "filters" class="audit-query workbench-query" ariaLabel="客户操作日志筛选" :fields="queryFields" preference-key="customer-audit-logs-query" @search="applyFilters" />
2026-08-13 11:57:44 +08:00
< LoadErrorState v-if = "loadError" title="客户操作日志加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></ template ></ LoadErrorState >
2026-10-20 23:34:11 +08:00
< div class = "workbench-table" v-else >< DataTable 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-10-20 10:11:31 +08:00
< template # actions = "{ row }" >< el-button class = "density-action" size = "small" : aria -label ="` 查看操作日志 : ${ auditEventLabel (( row as PlatformAuditLogListItem ) .eventType ) } , ${ formatDate (( row as PlatformAuditLogListItem ) .occurredAt ) } `" @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-10-20 23:34:11 +08:00
< template # eventType = "{ row }" >< el-tooltip : content = "(row as PlatformAuditLogListItem).eventType" placement = "top" :show-after = "500" >< span class = "event-label workbench-badge is-toned" >{{ auditEventLabel (( row as PlatformAuditLogListItem ). eventType ) }}</ span ></ el-tooltip ></ template >
2026-08-18 09:46:34 +08:00
< 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 >
2026-10-20 23:34:11 +08:00
</ DataTable ></ div >
2026-08-13 11:57:44 +08:00
</ template >
2026-10-20 23:34:11 +08:00
< AppDrawer class = "customer-audit-drawer admin-density-drawer admin-density-drawer--detail admin-workbench is-drawer" v-model = "detailOpen" :title="detailTitle" size="min(920px, 96vw)" :loading="detailLoading" :show-footer="false" >
2026-10-20 10:11:31 +08:00
< LoadErrorState v-if = "detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="loadDetail"><template #details><ApiErrorMeta :details="detailError" /></ template ></ LoadErrorState >
< div v-else-if = "detail" class="density-dialog-sections" >
< section class = "density-dialog-section" data -tone =" source " >
< h3 class = "density-dialog-section__title" >{{ auditEventLabel ( detail . eventType ) }} < el-tag : type = "detail.success ? 'success' : 'danger'" effect = "light" >{{ detail . success ? '操作成功' : '操作失败' }}</ el-tag ></ h3 >
< p class = "audit-event-description" >{{ auditDescriptionLabel ( detail . description ) }}</ p >
2026-08-18 09:46:34 +08:00
</ section >
2026-10-20 23:34:11 +08:00
< section class = "density-dialog-section workbench-evidence" data -tone =" source " >
2026-10-20 10:11:31 +08:00
< h3 class = "density-dialog-section__title" > 操作信息 </ h3 >
< table class = "density-table audit-detail-table" aria -label =" 操作日志基础信息 " >< tbody >
< tr >< th scope = "row" > 操作人 </ th >< td >{{ auditOperatorLabel ( detail . username ) }}</ td >< th scope = "row" > 发生时间 </ th >< td >{{ formatDate ( detail . occurredAt ) }}</ td ></ tr >
< tr >< th scope = "row" > 来源服务 </ th >< td >{{ auditServiceLabel ( detail . serviceName ) }}< small class = "audit-service-code density-dialog-note" >{{ display ( detail . serviceName ) }}</ small ></ td >< th scope = "row" > 客户端 IP </ th >< td >{{ display ( detail . clientIp ) }}</ td ></ tr >
< tr >< th scope = "row" > 业务资源 </ th >< td >{{ auditResourceLabel ( detail . resourceType ) }}</ td >< th scope = "row" > 处理耗时 </ th >< td >{{ detail . durationMs == null ? '—' : ` ${ detail . durationMs } ms` }}</ td ></ tr >
< tr >< th scope = "row" > 请求地址 </ th >< td colspan = "3" >< span class = "audit-request" >< el-tag size = "small" effect = "plain" >{{ auditHttpMethodLabel ( detail . httpMethod ) }}</ el-tag >< code >{{ display ( detail . requestUri ) }}</ code ></ span ></ td ></ tr >
< tr >< th scope = "row" > 请求标识 </ th >< td colspan = "3" >< code >{{ display ( detail . requestId ) }}</ code ></ td ></ tr >
< tr v-if = "!detail.success && detail.errorCode"><th scope="row">错误码(诊断)</th><td colspan="3" >< code >{{ detail . errorCode }}</ code ></ td ></ tr >
< tr v-if = "detail.traceId"><th scope="row">链路标识</th><td colspan="3" >< code >{{ detail . traceId }}</ code ></ td ></ tr >
</ tbody ></ table >
2026-08-18 09:46:34 +08:00
</ section >
2026-10-20 10:11:31 +08:00
< el-alert v-if = "!detail.success" type="error" :closable="false" show-icon :title="detail.errorMessage || '操作失败,请查看诊断信息'" />
< div class = "audit-snapshots" >
2026-10-20 15:48:23 +08:00
< section class = "density-dialog-section" data -tone =" source " >< h3 class = "density-dialog-section__title" > 操作前快照 </ h3 >< pre class = "density-code-block" >{{ formatSnapshot ( detail . beforeData ) }}</ pre ></ section >
< section class = "density-dialog-section" data -tone =" source " >< h3 class = "density-dialog-section__title" > 操作结果快照 </ h3 >< pre class = "density-code-block" >{{ formatSnapshot ( detail . afterData ) }}</ pre ></ section >
2026-10-20 10:11:31 +08:00
</ div >
</ div >
2026-08-13 11:57:44 +08:00
</ AppDrawer >
</ section >
</ template >
< style scoped >
2026-09-14 17:06:55 +08:00
2026-09-14 17:54:21 +08:00
2026-10-20 23:34:11 +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 ( -- workbench - border ); }. panel - title h2 { margin : 0 ; color : var ( -- workbench - text ); font - size : var ( -- admin - font - section , 14 px ); }. panel - title p { margin : 5 px 0 0 ; color : var ( -- workbench - muted ); font - size : var ( -- admin - font - caption , 12 px ); }. panel - title > svg { color : var ( -- workbench - 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 : var ( -- workbench - muted ); text - align : center ; }. module - empty strong { color : var ( -- workbench - text ); font - size : var ( -- admin - font - body , 13 px ); }. module - empty p { margin : 0 ; color : var ( -- workbench - 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 ; overflow : hidden ; line - height : 1.35 ; text - overflow : ellipsis ; white - space : nowrap ; }. service - label { display : block ; overflow : hidden ; color : var ( -- workbench - text ); text - overflow : ellipsis ; white - space : nowrap ; }
2026-10-20 10:11:31 +08:00
. audit - event - description { margin : 0 ; overflow - wrap : anywhere ; }
. audit - detail - table { table - layout : fixed ; }
. audit - detail - table th { width : 92 px ; }
. audit - service - code { display : block ; }
. audit - request { display : flex ; align - items : center ; gap : var ( -- admin - space - 3 ); }
. audit - request code { min - width : 0 ; }
. audit - snapshots { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : var ( -- admin - space - 5 ); }
. audit - snapshots > . density - dialog - section { margin - bottom : 0 ; }
@ media ( max - width : 720 px ) { . audit - snapshots { grid - template - columns : 1 fr ; } }
2026-08-13 11:57:44 +08:00
</ style >