移除用户系统级角色,权限完全迁移至项目级管理

- 用户注册/创建不再需要选择角色,账号管理只管资料和状态
- 移除前后端所有系统级 role 字段的暴露,改用 is_admin 标识管理员
- PM(项目负责人)角色自动拥有全部项目权限且不可修改
- 系统管理员在项目中的成员身份不可被删除
- 管理界面全面美化

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-20 15:52:17 +08:00
parent 6cefa620e4
commit 5b97ea32ab
35 changed files with 1937 additions and 481 deletions
+448 -141
View File
@@ -1,28 +1,56 @@
<template>
<div class="page">
<div class="page page--flush">
<div class="main-content-flat unified-shell">
<div class="audit-toolbar">
<!-- 统计概览 -->
<div class="stats-row">
<div class="stat-card stat-card--total">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ total }}</span>
<span class="stat-label">日志总数</span>
</div>
</div>
<div class="stat-card stat-card--success">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ successCount }}</span>
<span class="stat-label">{{ TEXT.audit.resultSuccess }}</span>
</div>
</div>
<div class="stat-card stat-card--fail">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ failCount }}</span>
<span class="stat-label">{{ TEXT.audit.resultFail }}</span>
</div>
</div>
<div class="stat-card stat-card--operators">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ operatorCount }}</span>
<span class="stat-label">操作人数</span>
</div>
</div>
</div>
<!-- 筛选栏 -->
<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="filters.eventType"
clearable
:placeholder="TEXT.modules.adminAuditLogs.filterEvent"
@change="onServerFilterChange"
class="filter-select-comp"
>
<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" />
</el-select>
</div>
<div class="filter-item-form">
<el-select
v-model="filters.operatorId"
clearable
:placeholder="TEXT.modules.adminAuditLogs.filterOperator"
filterable
@change="onServerFilterChange"
class="filter-select-comp"
>
<el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="onServerFilterChange" class="filter-select-comp">
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
</el-select>
</div>
@@ -48,6 +76,7 @@
<div class="filter-spacer"></div>
<el-dropdown trigger="click" @command="handleExportCommand" class="audit-export-dropdown">
<el-button plain class="audit-export-btn" :loading="exportLoading">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
导出 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
@@ -59,46 +88,56 @@
</el-dropdown>
</el-form>
</div>
<!-- 日志表格 -->
<div class="unified-section table-section">
<el-table :data="logs" v-loading="loading" class="audit-table" style="width: 100%" stripe table-layout="fixed">
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" align="center" show-overflow-tooltip>
<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">
<template #default="scope">
<span class="text-gray-500">{{ displayDateTime(scope.row.timestamp) }}</span>
<span class="time-text">{{ displayDateTime(scope.row.timestamp) }}</span>
</template>
</el-table-column>
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" show-overflow-tooltip />
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" show-overflow-tooltip>
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="130">
<template #default="scope">
<el-tag effect="plain">{{ scope.row.eventLabel }}</el-tag>
<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">
<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" show-overflow-tooltip>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target">
<template #default="scope">
<span v-if="scope.row.targetTypeLabel">
<strong>{{ scope.row.targetTypeLabel }}</strong>
<span class="text-gray-500" v-if="scope.row.targetName">{{ scope.row.targetName }}</span>
</span>
<span v-else>{{ TEXT.common.fallback }}</span>
<div class="target-cell">
<span v-if="scope.row.targetTypeLabel" class="target-text">
<strong>{{ scope.row.targetTypeLabel }}</strong><span v-if="scope.row.targetName" class="text-muted">{{ scope.row.targetName }}</span>
</span>
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" show-overflow-tooltip>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff">
<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">{{ line }}</div>
<div v-for="(line, idx) in getDiffPreview(scope.row)" :key="idx" class="diff-line">{{ formatDiffLine(line) }}</div>
<div class="diff-more-row" v-if="scope.row.diffText.length > DIFF_PREVIEW_LIMIT">
<span class="diff-more-text">其余 {{ scope.row.diffText.length - DIFF_PREVIEW_LIMIT }} </span>
<el-button link type="primary" @click="openDetail(scope.row)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="openDetail(scope.row)">{{ TEXT.common.actions.view }}</el-button>
</div>
</div>
<span v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</span>
<span v-else class="text-muted">{{ TEXT.audit.emptyValue }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="110" align="center">
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="80" align="center">
<template #default="scope">
<el-tag :type="scope.row.result === 'SUCCESS' ? 'success' : 'danger'" effect="dark" size="small">
<span class="result-badge" :class="scope.row.result === 'SUCCESS' ? 'result--success' : 'result--fail'">
{{ scope.row.resultLabel }}
</el-tag>
</span>
</template>
</el-table-column>
</el-table>
@@ -117,43 +156,88 @@
</div>
</div>
<!-- 详情抽屉 -->
<el-drawer
v-if="detailVisible"
v-model="detailVisible"
direction="rtl"
size="560px"
size="580px"
:close-on-click-modal="false"
:show-close="false"
class="audit-detail-drawer"
>
<template #header>
<div class="drawer-header">
<div class="drawer-title">审计日志详情</div>
<el-button @click="detailVisible = false">{{ TEXT.common.actions.close }}</el-button>
<div class="drawer-title-row">
<div class="drawer-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="18" height="18"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<span class="drawer-title">审计详情</span>
</div>
<el-button @click="detailVisible = false" size="small">{{ TEXT.common.actions.close }}</el-button>
</div>
</template>
<template v-if="selectedLog">
<!-- 基本信息 -->
<div class="detail-meta-grid">
<div class="meta-item"><span class="meta-label">时间</span><span>{{ displayDateTime(selectedLog.timestamp) }}</span></div>
<div class="meta-item"><span class="meta-label">操作人</span><span>{{ selectedLog.actorName || TEXT.common.fallback }}</span></div>
<div class="meta-item"><span class="meta-label">事件</span><span>{{ selectedLog.eventLabel || TEXT.common.fallback }}</span></div>
<div class="meta-item"><span class="meta-label">结果</span><span>{{ selectedLog.resultLabel || TEXT.common.fallback }}</span></div>
<div class="meta-item meta-item-full">
<span class="meta-label">对象</span>
<span>{{ selectedLog.targetTypeLabel || TEXT.common.fallback }}<span v-if="selectedLog.targetName">{{ selectedLog.targetName }}</span></span>
<div class="meta-card">
<span class="meta-label">时间</span>
<span class="meta-value">{{ displayDateTime(selectedLog.timestamp) }}</span>
</div>
<div class="meta-card">
<span class="meta-label">操作人</span>
<span class="meta-value">{{ selectedLog.actorName || TEXT.common.fallback }}</span>
</div>
<div class="meta-card">
<span class="meta-label">事件</span>
<span class="meta-value">{{ selectedLog.eventLabel || TEXT.common.fallback }}</span>
</div>
<div class="meta-card">
<span class="meta-label">结果</span>
<span class="meta-value">
<span class="result-badge" :class="selectedLog.result === 'SUCCESS' ? 'result--success' : 'result--fail'">{{ selectedLog.resultLabel }}</span>
</span>
</div>
<div class="meta-item meta-item-full"><span class="meta-label">动作</span><span>{{ selectedLog.actionText || TEXT.common.fallback }}</span></div>
</div>
<div class="detail-meta-grid detail-meta-full">
<div class="meta-card meta-card-full">
<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">
<span class="meta-label">动作</span>
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
</div>
</div>
<!-- 变更明细 -->
<div class="detail-section">
<div class="detail-section-title">变更明细</div>
<el-scrollbar max-height="58vh">
<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>
</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">
<span class="detail-line-index">{{ Number(idx) + 1 }}.</span>
<span class="detail-line-text">{{ line }}</span>
<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>
</div>
</div>
</div>
<div v-else class="text-gray-400">{{ TEXT.audit.emptyValue }}</div>
<div v-else class="empty-diff">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32" style="color:var(--ctms-text-disabled)"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-muted">无变更记录</span>
</div>
</el-scrollbar>
</div>
</template>
@@ -204,6 +288,33 @@ const filters = ref({
range: [],
});
const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCCESS') === 'SUCCESS').length);
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 '-';
try {
return JSON.stringify(line, null, 0);
} catch {
return String(line);
}
};
const parseDiffLine = (line: string): { field: string; oldVal: string; newVal: string } | null => {
const match = line.match(/^(.+?)(.+?)\s*->\s*(.+)$/);
if (!match) return null;
return { field: match[1], oldVal: match[2], newVal: match[3] };
};
const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
value,
label: cfg.label,
@@ -242,7 +353,6 @@ const loadUsers = async () => {
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 });
const items = Array.isArray(data) ? data : (data as any).items || [];
users.value = items.map((m: any) => ({
@@ -421,11 +531,7 @@ const confirmExport = async (scope: "system" | "project") => {
entity_type: "audit_log",
entity_id: null,
detail: JSON.stringify(
{
targetName: currentStudy.name,
scope,
exportedCount: events.length,
},
{ targetName: currentStudy.name, scope, exportedCount: events.length },
null,
0
),
@@ -444,31 +550,52 @@ onMounted(async () => {
</script>
<style scoped>
.page {
/* 统计卡片 */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--unified-shell-divider);
}
.stat-card {
display: flex;
flex-direction: column;
gap: 0;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
background: var(--ctms-neutral-100);
transition: var(--ctms-transition);
}
.main-content-flat {
width: 100%;
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
padding: 0;
.stat-card:hover {
transform: translateY(-1px);
box-shadow: var(--ctms-shadow-sm);
}
.main-content-flat :deep(.el-card__body) {
padding: 0;
.stat-icon {
width: 38px;
height: 38px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.table-section {
padding: 0;
}
.stat-icon svg { width: 20px; height: 20px; }
.stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); }
.stat-card--success .stat-icon { background: #e6f4ed; color: var(--ctms-success); }
.stat-card--fail .stat-icon { background: #fde8e8; color: var(--ctms-danger); }
.stat-card--operators .stat-icon { background: #eef2f6; color: var(--ctms-info); }
.stat-body { display: flex; flex-direction: column; }
.stat-value { font-size: 22px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); }
.stat-label { font-size: 12px; color: var(--ctms-text-secondary); margin-top: 2px; }
/* 筛选栏 */
.audit-toolbar {
padding: 10px 16px;
border-bottom: 1px solid var(--unified-shell-divider);
}
@@ -486,112 +613,195 @@ onMounted(async () => {
flex-shrink: 0;
}
.filter-select-comp {
width: 132px;
}
.filter-select-result {
width: 96px;
}
.date-range-picker-comp {
width: 248px !important;
}
.filter-spacer {
flex: 1;
}
.audit-export-dropdown {
flex-shrink: 0;
}
.filter-select-comp { width: 132px; }
.filter-select-result { width: 96px; }
.date-range-picker-comp { width: 248px !important; }
.filter-spacer { flex: 1; }
.audit-export-dropdown { flex-shrink: 0; }
.audit-export-btn {
border-radius: 8px !important;
font-weight: 600;
height: 32px;
color: #3b6cb5 !important;
border-color: #b3cce6 !important;
color: var(--ctms-primary) !important;
border-color: #c0cdd7 !important;
background: #f0f6ff !important;
}
.audit-export-btn:hover,
.audit-export-btn:focus {
color: #2a5699 !important;
border-color: #8cb4de !important;
.audit-export-btn:hover {
color: var(--ctms-primary-hover) !important;
border-color: #9bb1c2 !important;
background: #e2edfa !important;
}
.diff-container {
font-size: 13px;
color: #4b5563;
line-height: 1.5;
/* 表格 */
.table-section { padding: 0; }
.audit-table :deep(.el-table__inner-wrapper::before) { display: none; }
.time-text {
font-size: 12px;
color: var(--ctms-text-secondary);
font-variant-numeric: tabular-nums;
}
.diff-line {
margin-bottom: 2px;
word-break: break-all;
}
.diff-more-row {
.actor-cell {
display: flex;
align-items: center;
gap: 8px;
}
.diff-more-text {
font-size: 12px;
color: #94a3b8;
.actor-avatar {
width: 26px;
height: 26px;
border-radius: 7px;
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-active));
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
}
.actor-name {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-tag {
display: inline-flex;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
background: #f0f4f8;
color: var(--ctms-text-regular);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.target-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.text-muted { color: var(--ctms-text-secondary); font-size: 12px; }
.result-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.result--success { background: #e6f4ed; color: #166534; }
.result--fail { background: #fde8e8; color: #991b1b; }
.diff-container {
font-size: 12px;
color: var(--ctms-text-regular);
line-height: 1.5;
overflow: hidden;
}
.diff-line {
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.diff-more-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.diff-more-text { font-size: 11px; color: var(--ctms-text-disabled); }
.pagination-wrap {
margin-top: 10px;
display: flex;
justify-content: flex-end;
padding: 0 0 6px;
}
.audit-table :deep(.el-table__inner-wrapper::before) {
display: none;
padding: 8px 16px;
}
/* 详情抽屉 */
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.drawer-title-row {
display: flex;
align-items: center;
gap: 10px;
}
.drawer-icon {
width: 34px;
height: 34px;
border-radius: 9px;
background: var(--ctms-primary-light);
color: var(--ctms-primary);
display: flex;
align-items: center;
justify-content: center;
}
.drawer-title {
font-size: 20px;
font-size: 17px;
font-weight: 700;
color: #11264d;
color: var(--ctms-text-main);
}
.detail-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 14px;
gap: 10px;
margin-bottom: 10px;
}
.meta-item {
.detail-meta-full {
grid-template-columns: 1fr;
}
.meta-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
border: 1px solid #e6edf7;
border-radius: 8px;
background: #fbfdff;
font-size: 13px;
color: #334155;
padding: 10px 12px;
border: 1px solid var(--ctms-border-color);
border-radius: 10px;
background: var(--ctms-neutral-100);
}
.meta-item-full {
.meta-card-full {
grid-column: 1 / -1;
}
.meta-label {
font-size: 12px;
color: #64748b;
font-size: 11px;
color: var(--ctms-text-secondary);
font-weight: 500;
}
.meta-value {
font-size: 13px;
color: var(--ctms-text-main);
word-break: break-word;
}
.detail-section {
@@ -599,34 +809,131 @@ onMounted(async () => {
}
.detail-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 700;
color: #1e3a5f;
margin-bottom: 8px;
color: var(--ctms-text-main);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--ctms-border-color);
}
.detail-section-title svg {
color: var(--ctms-primary);
}
.diff-count {
font-size: 11px;
font-weight: 600;
color: var(--ctms-text-secondary);
background: var(--ctms-neutral-100);
padding: 1px 6px;
border-radius: 4px;
margin-left: auto;
}
.detail-lines {
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 6px;
}
.detail-line-row {
display: flex;
align-items: flex-start;
gap: 6px;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--ctms-border-color);
background: #fff;
transition: var(--ctms-transition);
}
.detail-line-row:hover {
border-color: var(--ctms-border-color-hover);
box-shadow: var(--ctms-shadow-sm);
}
.detail-line-index {
color: var(--ctms-text-disabled);
min-width: 20px;
font-size: 11px;
font-weight: 700;
text-align: center;
padding-top: 2px;
background: var(--ctms-neutral-100);
border-radius: 4px;
height: 20px;
line-height: 20px;
}
.detail-line-content {
flex: 1;
min-width: 0;
}
.diff-field {
display: inline-block;
font-size: 12px;
font-weight: 600;
color: var(--ctms-primary);
margin-bottom: 4px;
}
.diff-values {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
}
.diff-old {
font-size: 12px;
color: var(--ctms-danger);
background: #fef2f2;
padding: 2px 6px;
border-radius: 4px;
word-break: break-word;
max-width: 45%;
text-decoration: line-through;
text-decoration-color: rgba(194, 75, 75, 0.3);
}
.diff-arrow {
color: var(--ctms-text-disabled);
flex-shrink: 0;
margin-top: 3px;
}
.diff-new {
font-size: 12px;
color: var(--ctms-success);
background: #f0fdf4;
padding: 2px 6px;
border-radius: 4px;
word-break: break-word;
max-width: 45%;
}
.detail-line-text {
color: var(--ctms-text-regular);
word-break: break-word;
font-size: 13px;
line-height: 1.5;
}
.detail-line-index {
color: #94a3b8;
min-width: 20px;
.empty-diff {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 30px 20px;
}
.detail-line-text {
color: #475569;
word-break: break-word;
@media (max-width: 768px) {
.stats-row { grid-template-columns: repeat(2, 1fr); }
.filter-form { flex-wrap: wrap; }
}
</style>