@@ -44,6 +44,11 @@
<!-- 筛选栏 -- >
< div class = "audit-toolbar unified-action-bar" >
< el-form :inline = "true" :model = "filters" class = "filter-form" >
< div class = "filter-item-form" >
< el-select v-model = "selectedStudyId" filterable :placeholder="TEXT.common.fields.projectName" @change="onProjectChange" class="filter-select-project" >
< el -option v-for = "project in studies" :key="project.id" :label="project.name" :value="project.id" / >
< / el-select >
< / div >
< div class = "filter-item-form" >
< el-select v-model = "filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="onServerFilterChange" class="filter-select-comp" >
< el -option v-for = "opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" / >
@@ -81,7 +86,6 @@
< / el-button >
< template # dropdown >
< el-dropdown-menu >
< el-dropdown-item v-if = "isAdmin" command="system" > {{ TEXT.modules.adminAuditLogs.exportSystem }} < / el -dropdown -item >
< el-dropdown-item v-if = "canProjectExport" command="project" > {{ TEXT.modules.adminAuditLogs.exportProject }} < / el -dropdown -item >
< / el-dropdown-menu >
< / template >
@@ -92,26 +96,24 @@
<!-- 日志表格 -- >
< div class = "unified-section table-section" >
< el-table :data = "logs" v-loading = "loading" class="audit-table" style="width: 100%" table-layout="fixed" >
< el -table -column prop = "timestamp" :label = "TEXT.modules.adminAuditLogs.columns.time" width = "155 " >
< el -table -column prop = "timestamp" :label = "TEXT.modules.adminAuditLogs.columns.time" width = "150 " >
< template # default = "scope" >
< span class = "time-text" > { { displayDateTime ( scope . row . timestamp ) } } < / span >
< / template >
< / el-table-column >
< el-table-column prop = "actorName" :label = "TEXT.modules.adminAuditLogs.columns.actor" width = "13 0" >
< el-table-column prop = "actorName" :label = "TEXT.modules.adminAuditLogs.columns.actor" width = "12 0" >
< template # default = "scope" >
< div class = "actor-cell" >
< div class = "actor-avatar" > { { getInitials ( scope . row . actorName ) } } < / div >
< span class = "actor-name" > { { scope . row . actorName } } < / span >
< / div >
< / template >
< / el-table-column >
< el-table-column prop = "eventLabel" :label = "TEXT.modules.adminAuditLogs.columns.event" width = "140 " >
< el-table-column prop = "eventLabel" :label = "TEXT.modules.adminAuditLogs.columns.event" width = "145 " >
< template # default = "scope" >
< span class = "event-tag" > { { scope . row . eventLabel } } < / span >
< / template >
< / el-table-column >
< el-table-column prop = "actionText" :label = "TEXT.modules.adminAuditLogs.columns.action" show -overflow -tooltip / >
< el-table-column :label = "TEXT.modules.adminAuditLogs.columns.target" >
< el-table-column :label = "TEXT.modules.adminAuditLogs.columns.target" min -width = " 240 " >
< template # default = "scope" >
< div class = "target-cell" >
< span v-if = "scope.row.targetTypeLabel" class="target-text" >
@@ -121,7 +123,7 @@
< / div >
< / template >
< / el-table-column >
< el-table-column :label = "TEXT.modules.adminAuditLogs.columns.diff" >
< el-table-column :label = "TEXT.modules.adminAuditLogs.columns.diff" min -width = " 360 " >
< template # default = "scope" >
< div v-if = "scope.row.diffText?.length" class="diff-container" >
< div v-for = "(line, idx) in getDiffPreview(scope.row)" :key="idx" class="diff-line" > {{ formatDiffLine ( line ) }} < / div >
@@ -133,7 +135,7 @@
< span v-else class = "text-muted" > { { TEXT . audit . emptyValue } } < / span >
< / template >
< / el-table-column >
< el-table-column :label = "TEXT.modules.adminAuditLogs.columns.result" width = "80 " align = "center" >
< el-table-column :label = "TEXT.modules.adminAuditLogs.columns.result" width = "76 " align = "center" >
< template # default = "scope" >
< span class = "result-badge" : class = "scope.row.result === 'SUCCESS' ? 'result--success' : 'result--fail'" >
{ { scope . row . resultLabel } }
@@ -199,12 +201,12 @@
< / span >
< / div >
< / div >
< div class = "detail-meta-grid detail-meta-full " >
< div class = "meta-card meta-card-full " >
< div class = "detail-meta-grid" >
< div class = "meta-card" >
< span class = "meta-label" > 对象 < / span >
< span class = "meta-value" > { { selectedLog . targetTypeLabel || TEXT . common . fallback } } < span v-if = "selectedLog.targetName" > : {{ selectedLog.targetName }} < / span > < / span >
< / div >
< div class = "meta-card meta-card-full " >
< div class = "meta-card" >
< span class = "meta-label" > 动作 < / span >
< span class = "meta-value" > { { selectedLog . actionText || TEXT . common . fallback } } < / span >
< / div >
@@ -215,22 +217,27 @@
< div class = "detail-section-title" >
< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke -width = " 2 " width = "14" height = "14" > < path d = "M12 20h9" / > < path d = "M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" / > < / svg >
变更明细
< span class = "diff-count" v-if = "selectedLog.diffText? .length" > {{ selectedLog.diffText.length }} 条 < / span >
< span class = "diff-count" v-if = "selectedDiffGroups .length" > {{ selectedDiffGroups.length }} 项 < / span >
< / div >
< el -scrollbar max -height = " 55vh " >
< div v-if = "selectedLog.diffText? .length" class="detail-lines" >
< div v-for = "(line , idx) in selectedLog.diffText" :key=" idx" class="detail-line-row" >
< div v-if = "selectedDiffGroups .length" class="detail-lines" >
< div v-for = "(group , idx) in selectedDiffGroups" :key="`${group.title}-${ idx}` " class="detail-line-row" >
< span class = "detail-line-index" > { { Number ( idx ) + 1 } } < / span >
< div class = "detail-line-content" >
< template v-if = "parseDiffLine(formatDiffLine(line))" >
< span class = "diff-field" > { { parseDiffLine ( formatDiffLine ( line ) ) ! . field } } < / span >
< div class = "diff-values" >
< span class = "diff-old" > { { parseDiffLine ( formatDiffLine ( line ) ) ! . oldVal } } < / span >
< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke -width = " 2 " width = "12" height = "12" class = "diff-arrow " > < line x1 = "5" y1 = "12" x2 = "19" y2 = "12" / > < polyline points = "12 5 19 12 12 19" / > < / svg >
< span class = "diff-new" > { { parseDiffLine ( formatDiffLine ( line ) ) ! . newVal } } < / span >
< / div >
< / template >
< span v-else class = "detail-line-text " > { { formatDiffLine ( line ) } } < / span >
< span v-if = "group.title" class="diff-group-title" > {{ group.title }} < / span >
< div class = "diff-group-lines" >
< template v-for = "(item, itemIdx) in group.items" :key="itemIdx" >
< div v-if = "item.parsed" class="diff-item-row" >
< span class = "diff-field " > { { item . parsed . field } } < / span >
< div class = "diff-values" >
< span class = "diff-old" > { { item . parsed . oldVal } } < / span >
< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke -width = " 2 " width = "12" height = "12" class = "diff-arrow" > < line x1 = "5" y1 = "12" x2 = "19" y2 = "12" / > < polyline points = "12 5 19 12 12 19" / > < / svg >
< span class = "diff-new " > { { item . parsed . newVal } } < / span >
< / div >
< / div >
< span v-else class = "detail-line-text" > { { item . text } } < / span >
< / template >
< / div >
< / div >
< / div >
< / div >
@@ -247,10 +254,11 @@
< script setup lang = "ts" >
import { computed , onMounted , ref } from "vue" ;
import { useRouter } from "vue-router" ;
import { useRoute , useRouter } from "vue-router" ;
import { ElMessage , ElMessageBox } from "element-plus" ;
import { ArrowDown } from "@element-plus/icons-vue" ;
import { createAuditEvent , fetchAuditLogs } from "../../api/auditLogs" ;
import { fetchStudies } from "../../api/studies" ;
import { fetchApiEndpointPermissions } from "../../api/projectPermissions" ;
import { fetchUsers } from "../../api/users" ;
import { listMembers } from "../../api/members" ;
@@ -263,15 +271,19 @@ import { displayDateTime } from "../../utils/display";
import { getProjectRole , isSystemAdmin } from "../../utils/roles" ;
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue" ;
import { TEXT } from "../../locales" ;
import type { Study } from "../../types/api" ;
const study = useStudyStore ( ) ;
const auth = useAuthStore ( ) ;
const router = useRouter ( ) ;
const route = useRoute ( ) ;
const loading = ref ( false ) ;
const exportLoading = ref ( false ) ;
const logs = ref < any [ ] > ( [ ] ) ;
const users = ref < any [ ] > ( [ ] ) ;
const studies = ref < Study [ ] > ( [ ] ) ;
const selectedStudy = ref < Study | null > ( null ) ;
const allLogs = ref < any [ ] > ( [ ] ) ;
const permissionMatrix = ref < any | null > ( null ) ;
const page = ref ( 1 ) ;
@@ -292,13 +304,6 @@ const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCC
const failCount = computed ( ( ) => allLogs . value . filter ( l => l . result === 'FAIL' ) . length ) ;
const operatorCount = computed ( ( ) => new Set ( allLogs . value . map ( l => l . actorId || l . actorName ) ) . size ) ;
const getInitials = ( name : string ) => {
if ( ! name ) return '?' ;
const parts = name . trim ( ) . split ( /\s+/ ) ;
if ( parts . length >= 2 ) return ( parts [ 0 ] [ 0 ] + parts [ 1 ] [ 0 ] ) . toUpperCase ( ) ;
return name . slice ( 0 , 2 ) . toUpperCase ( ) ;
} ;
const formatDiffLine = ( line : any ) : string => {
if ( typeof line === 'string' ) return line ;
if ( line === null || line === undefined ) return '-' ;
@@ -310,11 +315,62 @@ const formatDiffLine = (line: any): string => {
} ;
const parseDiffLine = ( line : string ) : { field : string ; oldVal : string ; newVal : string } | null => {
const match = line . match ( /^(.+?): (.+ ?)\s*->\s*(.+)$/ ) ;
const match = line . match ( /^(.+)[:: ]\s* (.* ?)\s*->\s*(.+)$/ ) ;
if ( ! match ) return null ;
return { field : match [ 1 ] , oldVal : match [ 2 ] , newVal : match [ 3 ] } ;
} ;
type DetailDiffItem = {
text : string ;
parsed : { field : string ; oldVal : string ; newVal : string } | null ;
} ;
type DetailDiffGroup = {
title : string ;
items : DetailDiffItem [ ] ;
} ;
const splitGroupedDiffField = ( field : string ) : { title : string ; field : string } | null => {
const parts = String ( field || "" )
. split ( "/" )
. map ( ( item ) => item . trim ( ) )
. filter ( Boolean ) ;
if ( parts . length < 3 ) return null ;
return {
title : parts . slice ( 0 , - 1 ) . join ( " / " ) ,
field : parts [ parts . length - 1 ] ,
} ;
} ;
const buildDetailDiffGroups = ( lines : any [ ] ) : DetailDiffGroup [ ] => {
const groups : DetailDiffGroup [ ] = [ ] ;
const groupedIndex = new Map < string , DetailDiffGroup > ( ) ;
lines . forEach ( ( line ) => {
const text = formatDiffLine ( line ) ;
const parsed = parseDiffLine ( text ) ;
const groupedField = parsed ? splitGroupedDiffField ( parsed . field ) : null ;
if ( ! parsed || ! groupedField ) {
groups . push ( { title : "" , items : [ { text , parsed } ] } ) ;
return ;
}
let group = groupedIndex . get ( groupedField . title ) ;
if ( ! group ) {
group = { title : groupedField . title , items : [ ] } ;
groupedIndex . set ( groupedField . title , group ) ;
groups . push ( group ) ;
}
group . items . push ( {
text ,
parsed : { ... parsed , field : groupedField . field } ,
} ) ;
} ) ;
return groups ;
} ;
const eventTypeOptions = Object . entries ( auditDict ) . map ( ( [ value , cfg ] ) => ( {
value ,
label : cfg . label ,
@@ -324,8 +380,20 @@ const resolveUserDisplayName = (u: any): string => {
} ;
const userOptions = computed ( ( ) => users . value . map ( ( u : any ) => ( { label : resolveUserDisplayName ( u ) , value : u . id } ) ) ) ;
const selectedDiffGroups = computed ( ( ) => buildDetailDiffGroups ( selectedLog . value ? . diffText || [ ] ) ) ;
const selectedStudyId = computed ( {
get : ( ) => selectedStudy . value ? . id || "" ,
set : ( studyId : string ) => {
selectedStudy . value = studies . value . find ( ( item ) => item . id === studyId ) || null ;
} ,
} ) ;
const isAdmin = computed ( ( ) => isSystemAdmin ( auth . user ) ) ;
const projectRole = computed ( ( ) => getProjectRole ( study . currentStudy , study . currentStudyRole ) ) ;
const projectRole = computed ( ( ) => {
const role = selectedStudy . value ? . id === study . currentStudy ? . id
? study . currentStudyRole
: ( selectedStudy . value as any ) ? . role _in _study || null ;
return getProjectRole ( selectedStudy . value , role ) ;
} ) ;
const canProjectExport = computed ( ( ) => {
if ( isAdmin . value ) return true ;
return projectRole . value === "PM" && isApiPermissionAllowed ( permissionMatrix . value ? . [ projectRole . value ] ? . [ "audit_logs:export" ] ) ;
@@ -336,14 +404,31 @@ const canAccessAuditLogs = computed(() => {
} ) ;
const ensureAccess = ( ) => {
if ( ! selectedStudy . value ) {
router . replace ( "/admin/projects" ) ;
return ;
}
if ( ! canAccessAuditLogs . value ) {
router . replace ( study . currentStudy ? "/project/overview" : "/admin/projects" ) ;
}
} ;
const loadAvailableStudies = async ( ) => {
const { data } = await fetchStudies ( ) ;
const items = ( ( data as any ) . items || [ ] ) as Study [ ] ;
const availableItems = isAdmin . value ? items : items . filter ( ( item ) => item . role _in _study === "PM" ) ;
studies . value = availableItems ;
const queryProjectId = typeof route . query . projectId === "string" ? route . query . projectId : "" ;
selectedStudy . value =
availableItems . find ( ( item ) => item . id === queryProjectId ) ||
availableItems . find ( ( item ) => item . id === study . currentStudy ? . id ) ||
availableItems [ 0 ] ||
null ;
} ;
const loadPermissionMatrix = async ( ) => {
if ( ! study . currentStudy ) return ;
const { data } = await fetchApiEndpointPermissions ( study . currentStudy . id ) ;
if ( ! selectedStudyId . value ) return ;
const { data } = await fetchApiEndpointPermissions ( selectedStudyId . value ) ;
permissionMatrix . value = data ;
} ;
@@ -352,8 +437,8 @@ const loadUsers = async () => {
if ( isAdmin . value ) {
const { data } = await fetchUsers ( { limit : 500 } ) ;
users . value = Array . isArray ( data ) ? data : ( data as any ) . items || [ ] ;
} else if ( study . currentStudy ) {
const { data } = await listMembers ( study . currentStudy . id , { limit : 500 } ) ;
} else if ( selectedStudyId . value ) {
const { data } = await listMembers ( selectedStudyId . value , { limit : 500 } ) ;
const items = Array . isArray ( data ) ? data : ( data as any ) . items || [ ] ;
users . value = items . map ( ( m : any ) => ( {
id : m . user _id ,
@@ -365,30 +450,37 @@ const loadUsers = async () => {
}
} ;
const fetchAuditLogPages = async ( baseParams : Record < string , any > = { } ) => {
const batchLimit = 500 ;
let skip = 0 ;
const allItems : any [ ] = [ ] ;
const params : Record < string , any > = {
... baseParams ,
skip ,
limit : batchLimit ,
} ;
while ( true ) {
params . skip = skip ;
const { data } = await fetchAuditLogs ( selectedStudyId . value , params ) ;
const items = Array . isArray ( data ) ? data : ( data as any ) . items || [ ] ;
allItems . push ( ... items ) ;
if ( items . length < batchLimit ) break ;
skip += items . length ;
}
return allItems ;
} ;
const loadLogs = async ( ) => {
if ( ! study . currentStudy ) return ;
if ( ! selectedStudyId . value ) return ;
loading . value = true ;
try {
if ( ! permissionMatrix . value ) {
await loadPermissionMatrix ( ) ;
}
const batchLimit = 500 ;
let skip = 0 ;
const allItems : any [ ] = [ ] ;
const params : Record < string , any > = {
skip ,
limit : batchLimit ,
const allItems = await fetchAuditLogPages ( {
action : filters . value . eventType || undefined ,
operator _id : filters . value . operatorId || undefined ,
} ;
while ( true ) {
params . skip = skip ;
const { data } = await fetchAuditLogs ( study . currentStudy . id , params ) ;
const items = Array . isArray ( data ) ? data : ( data as any ) . items || [ ] ;
allItems . push ( ... items ) ;
if ( items . length < batchLimit ) break ;
skip += items . length ;
}
} ) ;
enrichLogs ( allItems ) ;
refreshPagedLogs ( ) ;
} catch ( e : any ) {
@@ -448,6 +540,23 @@ const onLocalFilterChange = () => {
refreshPagedLogs ( ) ;
} ;
const onProjectChange = async ( ) => {
permissionMatrix . value = null ;
users . value = [ ] ;
allLogs . value = [ ] ;
logs . value = [ ] ;
total . value = 0 ;
page . value = 1 ;
await router . replace ( {
path : "/admin/audit-logs" ,
query : { ... route . query , projectId : selectedStudyId . value || undefined } ,
} ) ;
await loadPermissionMatrix ( ) . catch ( ( ) => { } ) ;
ensureAccess ( ) ;
await loadUsers ( ) ;
await loadLogs ( ) ;
} ;
const onPageChange = ( p : number ) => {
page . value = p ;
refreshPagedLogs ( ) ;
@@ -470,17 +579,13 @@ const openDetail = (log: any) => {
} ;
const fetchAllForExport = async ( ) => {
if ( ! study . currentStudy ) return [ ] ;
if ( ! selectedStudyId . value ) return [ ] ;
exportLoading . value = true ;
try {
const params : Record < string , any > = {
skip : 0 ,
limit : 2000 ,
const items = await fetchAuditLogPages ( {
action : filters . value . eventType || undefined ,
operator _id : filters . value . operatorId || undefined ,
} ;
const { data } = await fetchAuditLogs ( study . currentStudy . id , params ) ;
const items = Array . isArray ( data ) ? data : ( data as any ) . items || [ ] ;
} ) ;
const userMap = users . value . reduce < Record < string , string > > ( ( acc , cur ) => {
acc [ cur . id ] = resolveUserDisplayName ( cur ) ;
return acc ;
@@ -504,13 +609,13 @@ const fetchAllForExport = async () => {
} ;
const handleExportCommand = ( command : string ) => {
confirmExport ( command as "system" | "project" ) ;
if ( command === "project" ) confirmExport ( ) ;
} ;
const confirmExport = async ( scope : "system" | "project" ) => {
const currentStudy = study . currentStudy ;
const confirmExport = async ( ) => {
const currentStudy = selectedStudy . value ;
if ( ! currentStudy ) return ;
if ( scope === "project" && ! canProjectExport . value ) return ;
if ( ! canProjectExport . value ) return ;
const ok = await ElMessageBox . confirm (
TEXT . modules . adminAuditLogs . exportConfirm ,
TEXT . modules . adminAuditLogs . exportConfirmTitle ,
@@ -522,20 +627,10 @@ const confirmExport = async (scope: "system" | "project") => {
ElMessage . warning ( TEXT . modules . adminAuditLogs . exportEmpty ) ;
return ;
}
const fileName =
scope === "system"
? ` ${ TEXT . modules . adminAuditLogs . exportSystemName } _ ${ new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) } `
: ` ${ TEXT . modules . adminAuditLogs . exportProjectName } _ ${ currentStudy . code || "" } _ ${ new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) } ` ;
const fileName = ` ${ TEXT . modules . adminAuditLogs . exportProjectName } _ ${ currentStudy . code || "" } _ ${ new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) } ` ;
exportAuditCsv ( events , { fileName } ) ;
await createAuditEvent ( currentStudy . id , {
action : scope === "system" ? "AUDIT_EXPORT_SYSTEM" : "AUDIT_EXPORT_PROJECT" ,
entity _type : "audit_log" ,
entity _id : null ,
detail : JSON . stringify (
{ targetName : currentStudy . name , scope , exportedCount : events . length } ,
null ,
0
) ,
await createAuditEvent ( selectedStudyId . value , {
action : "AUDIT_EXPORT_PROJECT" ,
} ) . catch ( ( ) => null ) ;
} ;
@@ -543,6 +638,7 @@ onMounted(async () => {
if ( ! auth . user && auth . token ) {
await auth . fetchMe ( ) . catch ( ( ) => { } ) ;
}
await loadAvailableStudies ( ) . catch ( ( ) => { } ) ;
await loadPermissionMatrix ( ) . catch ( ( ) => { } ) ;
ensureAccess ( ) ;
await loadUsers ( ) ;
@@ -615,6 +711,7 @@ onMounted(async () => {
}
. filter - select - comp { width : 132 px ; }
. filter - select - project { width : 180 px ; }
. filter - select - result { width : 96 px ; }
. date - range - picker - comp { width : 248 px ! important ; }
. filter - spacer { flex : 1 ; }
@@ -637,6 +734,12 @@ onMounted(async () => {
/* 表格 */
. table - section { padding : 0 ; }
. audit - table : deep ( . el - table _ _inner - wrapper : : before ) { display : none ; }
. audit - table : deep ( . el - table _ _cell ) {
padding : 8 px 0 ;
}
. audit - table : deep ( . cell ) {
padding : 0 12 px ;
}
. time - text {
font - size : 12 px ;
@@ -645,26 +748,11 @@ onMounted(async () => {
}
. actor - cell {
display : flex ;
align - items : center ;
gap : 8 px ;
}
. actor - avatar {
width : 26 px ;
height : 26 px ;
border - radius : 7 px ;
background : linear - gradient ( 135 deg , var ( -- ctms - primary ) , var ( -- ctms - primary - active ) ) ;
color : # fff ;
display : flex ;
align - items : center ;
justify - content : center ;
font - size : 10 px ;
font - weight : 700 ;
flex - shrink : 0 ;
min - width : 0 ;
}
. actor - name {
display : block ;
font - size : 13 px ;
white - space : nowrap ;
overflow : hidden ;
@@ -692,6 +780,23 @@ onMounted(async () => {
font - size : 13 px ;
}
. target - text {
display : inline - flex ;
align - items : baseline ;
max - width : 100 % ;
min - width : 0 ;
}
. target - text strong {
flex - shrink : 0 ;
}
. target - text . text - muted {
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
. text - muted { color : var ( -- ctms - text - secondary ) ; font - size : 12 px ; }
. result - badge {
@@ -712,6 +817,7 @@ onMounted(async () => {
color : var ( -- ctms - text - regular ) ;
line - height : 1.5 ;
overflow : hidden ;
max - width : 100 % ;
}
. diff - line {
@@ -775,10 +881,6 @@ onMounted(async () => {
margin - bottom : 10 px ;
}
. detail - meta - full {
grid - template - columns : 1 fr ;
}
. meta - card {
display : flex ;
flex - direction : column ;
@@ -789,10 +891,6 @@ onMounted(async () => {
background : var ( -- ctms - neutral - 100 ) ;
}
. meta - card - full {
grid - column : 1 / - 1 ;
}
. meta - label {
font - size : 11 px ;
color : var ( -- ctms - text - secondary ) ;
@@ -875,12 +973,35 @@ onMounted(async () => {
min - width : 0 ;
}
. diff - group - title {
display : block ;
font - size : 13 px ;
font - weight : 700 ;
color : var ( -- ctms - primary ) ;
margin - bottom : 8 px ;
word - break : break - word ;
}
. diff - group - lines {
display : flex ;
flex - direction : column ;
gap : 8 px ;
}
. diff - item - row {
display : grid ;
grid - template - columns : minmax ( 72 px , 128 px ) minmax ( 0 , 1 fr ) ;
gap : 10 px ;
align - items : start ;
}
. diff - field {
display : inline - block ;
font - size : 12 px ;
font - weight : 600 ;
color : var ( -- ctms - primary ) ;
margin - bottom : 4 px ;
color : var ( -- ctms - text - regular ) ;
line - height : 1.8 ;
word - break : break - word ;
}
. diff - values {
@@ -936,5 +1057,6 @@ onMounted(async () => {
@ media ( max - width : 768 px ) {
. stats - row { grid - template - columns : repeat ( 2 , 1 fr ) ; }
. filter - form { flex - wrap : wrap ; }
. diff - item - row { grid - template - columns : 1 fr ; gap : 4 px ; }
}
< / style >