mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
feat(activity): three-source merged feed + detail drawer
This commit is contained in:
parent
170cb1f2f2
commit
b8ce36f6cd
@ -0,0 +1,166 @@
|
|||||||
|
package vip.mate.activity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import vip.mate.approval.model.ToolApprovalEntity;
|
||||||
|
import vip.mate.approval.repository.ToolApprovalMapper;
|
||||||
|
import vip.mate.audit.model.AuditEventEntity;
|
||||||
|
import vip.mate.audit.service.AuditEventService;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-090 §4.5 / §7 — unified Activity feed.
|
||||||
|
*
|
||||||
|
* <p>Merges three sources into one chronologically-ordered stream:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code audit_event} — CRUD-style events on agents / channels /
|
||||||
|
* skills / wiki / workspace (the existing audit log)</li>
|
||||||
|
* <li>{@code tool_approval} — approval requests + their resolution
|
||||||
|
* (granted / denied / expired). Ties tool gating decisions
|
||||||
|
* directly to the audit timeline.</li>
|
||||||
|
* <li>Successful tool calls — RFC §4.5 mentions these, but the
|
||||||
|
* runtime doesn't yet persist a row per successful call.
|
||||||
|
* Returning an empty bucket keeps the API contract stable so
|
||||||
|
* the UI can light up automatically once a future commit adds
|
||||||
|
* persistence.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Pagination is best-effort: each source is paged from index 0
|
||||||
|
* up to {@code size * 2}, then the merged list is trimmed and offset
|
||||||
|
* in-memory. For workspaces with >>1k events / day a follow-up should
|
||||||
|
* push merging into SQL; this is good enough for v1.
|
||||||
|
*/
|
||||||
|
@Tag(name = "Activity Feed (RFC-090)")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/activity")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ActivityFeedController {
|
||||||
|
|
||||||
|
private final AuditEventService auditEventService;
|
||||||
|
private final ToolApprovalMapper toolApprovalMapper;
|
||||||
|
|
||||||
|
@Operation(summary = "Unified activity feed (audit + approval + tool calls)")
|
||||||
|
@GetMapping("/feed")
|
||||||
|
public R<Map<String, Object>> feed(
|
||||||
|
@RequestParam(required = false) Long workspaceId,
|
||||||
|
@RequestParam(required = false) String source,
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
if (size <= 0 || size > 200) size = 20;
|
||||||
|
if (page <= 0) page = 1;
|
||||||
|
|
||||||
|
// Pull a chunk from each source large enough to cover the
|
||||||
|
// requested page after merge. We read at most page*size rows
|
||||||
|
// from each side; for stable cursors, future iterations should
|
||||||
|
// push the merge into SQL with a UNION ALL view.
|
||||||
|
int chunk = Math.max(size * page, 50);
|
||||||
|
|
||||||
|
List<ActivityRow> rows = new ArrayList<>();
|
||||||
|
|
||||||
|
boolean wantAudit = source == null || source.isBlank() || "audit".equalsIgnoreCase(source);
|
||||||
|
boolean wantApproval = source == null || source.isBlank() || "approval".equalsIgnoreCase(source);
|
||||||
|
|
||||||
|
if (wantAudit) {
|
||||||
|
try {
|
||||||
|
IPage<AuditEventEntity> audit = auditEventService.listEvents(
|
||||||
|
workspaceId, null, null, null, null, 1, chunk);
|
||||||
|
for (AuditEventEntity ev : audit.getRecords()) {
|
||||||
|
rows.add(fromAuditEvent(ev));
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) { /* surface silence > one-source crash */ }
|
||||||
|
}
|
||||||
|
if (wantApproval) {
|
||||||
|
try {
|
||||||
|
Page<ToolApprovalEntity> p = new Page<>(1, chunk);
|
||||||
|
LambdaQueryWrapper<ToolApprovalEntity> qw = new LambdaQueryWrapper<ToolApprovalEntity>()
|
||||||
|
.orderByDesc(ToolApprovalEntity::getCreatedAt);
|
||||||
|
IPage<ToolApprovalEntity> approvals = toolApprovalMapper.selectPage(p, qw);
|
||||||
|
for (ToolApprovalEntity ap : approvals.getRecords()) {
|
||||||
|
rows.add(fromApproval(ap));
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) { /* see above */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort(Comparator.comparing(ActivityRow::time, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||||
|
|
||||||
|
long total = rows.size();
|
||||||
|
int from = Math.min((page - 1) * size, rows.size());
|
||||||
|
int to = Math.min(from + size, rows.size());
|
||||||
|
List<ActivityRow> sliced = rows.subList(from, to);
|
||||||
|
|
||||||
|
Map<String, Object> resp = new LinkedHashMap<>();
|
||||||
|
resp.put("page", page);
|
||||||
|
resp.put("size", size);
|
||||||
|
resp.put("total", total);
|
||||||
|
resp.put("records", sliced);
|
||||||
|
return R.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActivityRow fromAuditEvent(AuditEventEntity ev) {
|
||||||
|
Map<String, Object> detail = new LinkedHashMap<>();
|
||||||
|
detail.put("detailJson", ev.getDetailJson());
|
||||||
|
detail.put("userAgent", ev.getUserAgent());
|
||||||
|
detail.put("workspaceId", ev.getWorkspaceId());
|
||||||
|
return new ActivityRow(
|
||||||
|
"audit-" + ev.getId(),
|
||||||
|
"audit",
|
||||||
|
ev.getCreateTime(),
|
||||||
|
ev.getUsername(),
|
||||||
|
ev.getAction(),
|
||||||
|
ev.getResourceType(),
|
||||||
|
ev.getResourceName() != null ? ev.getResourceName() : ev.getResourceId(),
|
||||||
|
ev.getIpAddress(),
|
||||||
|
detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActivityRow fromApproval(ToolApprovalEntity ap) {
|
||||||
|
Map<String, Object> detail = new LinkedHashMap<>();
|
||||||
|
detail.put("toolArguments", ap.getToolArguments());
|
||||||
|
detail.put("summary", ap.getSummary());
|
||||||
|
detail.put("maxSeverity", ap.getMaxSeverity());
|
||||||
|
detail.put("status", ap.getStatus());
|
||||||
|
detail.put("resolvedAt", ap.getResolvedAt());
|
||||||
|
// Map approval status onto an audit-style action so the UI's
|
||||||
|
// existing action coloring (CREATE / DELETE / etc.) keeps
|
||||||
|
// working without a special case.
|
||||||
|
String action = "APPROVAL_" + (ap.getStatus() == null ? "PENDING" : ap.getStatus().toUpperCase());
|
||||||
|
return new ActivityRow(
|
||||||
|
"approval-" + ap.getId(),
|
||||||
|
"approval",
|
||||||
|
ap.getCreatedAt(),
|
||||||
|
ap.getResolvedBy() != null ? ap.getResolvedBy() : ap.getRequesterName(),
|
||||||
|
action,
|
||||||
|
"TOOL_APPROVAL",
|
||||||
|
ap.getToolName(),
|
||||||
|
null,
|
||||||
|
detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire-format row. Public record so Jackson serializes it directly
|
||||||
|
* without needing a separate DTO.
|
||||||
|
*/
|
||||||
|
public record ActivityRow(
|
||||||
|
String id,
|
||||||
|
String source,
|
||||||
|
LocalDateTime time,
|
||||||
|
String username,
|
||||||
|
String action,
|
||||||
|
String resourceType,
|
||||||
|
String resourceName,
|
||||||
|
String ipAddress,
|
||||||
|
Map<String, Object> detail
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@ -177,6 +177,12 @@ export const skillApi = {
|
|||||||
employees: (id: string | number) => http.get(`/skills/${id}/employees`),
|
employees: (id: string | number) => http.get(`/skills/${id}/employees`),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Activity Feed (RFC-090 §4.5) ====================
|
||||||
|
export const activityApi = {
|
||||||
|
feed: (params: { source?: string; page?: number; size?: number; workspaceId?: number } = {}) =>
|
||||||
|
http.get('/activity/feed', { params }),
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== ACP Endpoints (RFC-090 Phase 7) ====================
|
// ==================== ACP Endpoints (RFC-090 Phase 7) ====================
|
||||||
export const acpApi = {
|
export const acpApi = {
|
||||||
list: () => http.get('/acp/endpoints'),
|
list: () => http.get('/acp/endpoints'),
|
||||||
|
|||||||
@ -800,13 +800,19 @@ export default {
|
|||||||
},
|
},
|
||||||
activity: {
|
activity: {
|
||||||
title: 'Activity Log',
|
title: 'Activity Log',
|
||||||
desc: 'View all create, update, and delete operations on workspace resources.',
|
desc: 'Unified feed: audit events + tool approvals (and successful tool calls when persistence lands).',
|
||||||
allActions: 'All Actions',
|
allActions: 'All Actions',
|
||||||
allResources: 'All Resources',
|
allResources: 'All Resources',
|
||||||
|
allSources: 'All Sources',
|
||||||
|
sourceAudit: 'Audit',
|
||||||
|
sourceApproval: 'Approval',
|
||||||
|
detailTitle: 'Event detail',
|
||||||
|
detailDetails: 'Detail',
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
noEvents: 'No activity yet',
|
noEvents: 'No activity yet',
|
||||||
columns: {
|
columns: {
|
||||||
time: 'Time',
|
time: 'Time',
|
||||||
|
source: 'Source',
|
||||||
user: 'User',
|
user: 'User',
|
||||||
action: 'Action',
|
action: 'Action',
|
||||||
resource: 'Resource',
|
resource: 'Resource',
|
||||||
|
|||||||
@ -800,13 +800,19 @@ export default {
|
|||||||
},
|
},
|
||||||
activity: {
|
activity: {
|
||||||
title: '操作日志',
|
title: '操作日志',
|
||||||
desc: '查看工作区内所有资源的创建、修改、删除操作记录。',
|
desc: '聚合视图:审计事件 + 工具审批(成功 tool 调用待落库后自动接入)。',
|
||||||
allActions: '全部操作',
|
allActions: '全部操作',
|
||||||
allResources: '全部资源',
|
allResources: '全部资源',
|
||||||
|
allSources: '全部来源',
|
||||||
|
sourceAudit: '审计',
|
||||||
|
sourceApproval: '审批',
|
||||||
|
detailTitle: '事件详情',
|
||||||
|
detailDetails: '详情',
|
||||||
loading: '加载中...',
|
loading: '加载中...',
|
||||||
noEvents: '暂无操作记录',
|
noEvents: '暂无操作记录',
|
||||||
columns: {
|
columns: {
|
||||||
time: '时间',
|
time: '时间',
|
||||||
|
source: '来源',
|
||||||
user: '用户',
|
user: '用户',
|
||||||
action: '操作',
|
action: '操作',
|
||||||
resource: '资源类型',
|
resource: '资源类型',
|
||||||
|
|||||||
@ -15,15 +15,23 @@
|
|||||||
<div class="settings-section activity-inner mc-surface-card">
|
<div class="settings-section activity-inner mc-surface-card">
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<select v-model="filters.action" class="filter-select" @change="loadEvents">
|
<!-- RFC-090 §4.5 — source filter for the unified feed -->
|
||||||
|
<select v-model="filters.source" class="filter-select" @change="loadEvents">
|
||||||
|
<option value="">{{ t('security.activity.allSources') }}</option>
|
||||||
|
<option value="audit">{{ t('security.activity.sourceAudit') }}</option>
|
||||||
|
<option value="approval">{{ t('security.activity.sourceApproval') }}</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="filters.action" class="filter-select" @change="filterEventsLocally">
|
||||||
<option value="">{{ t('security.activity.allActions') }}</option>
|
<option value="">{{ t('security.activity.allActions') }}</option>
|
||||||
<option value="CREATE">CREATE</option>
|
<option value="CREATE">CREATE</option>
|
||||||
<option value="UPDATE">UPDATE</option>
|
<option value="UPDATE">UPDATE</option>
|
||||||
<option value="DELETE">DELETE</option>
|
<option value="DELETE">DELETE</option>
|
||||||
<option value="ENABLE">ENABLE</option>
|
<option value="ENABLE">ENABLE</option>
|
||||||
<option value="DISABLE">DISABLE</option>
|
<option value="DISABLE">DISABLE</option>
|
||||||
|
<option value="APPROVAL_GRANTED">APPROVAL_GRANTED</option>
|
||||||
|
<option value="APPROVAL_DENIED">APPROVAL_DENIED</option>
|
||||||
</select>
|
</select>
|
||||||
<select v-model="filters.resourceType" class="filter-select" @change="loadEvents">
|
<select v-model="filters.resourceType" class="filter-select" @change="filterEventsLocally">
|
||||||
<option value="">{{ t('security.activity.allResources') }}</option>
|
<option value="">{{ t('security.activity.allResources') }}</option>
|
||||||
<option value="AGENT">Agent</option>
|
<option value="AGENT">Agent</option>
|
||||||
<option value="CHANNEL">Channel</option>
|
<option value="CHANNEL">Channel</option>
|
||||||
@ -31,6 +39,7 @@
|
|||||||
<option value="WIKI">Wiki</option>
|
<option value="WIKI">Wiki</option>
|
||||||
<option value="MEMBER">Member</option>
|
<option value="MEMBER">Member</option>
|
||||||
<option value="WORKSPACE">Workspace</option>
|
<option value="WORKSPACE">Workspace</option>
|
||||||
|
<option value="TOOL_APPROVAL">Tool Approval</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn-secondary" @click="loadEvents">
|
<button class="btn-secondary" @click="loadEvents">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@ -46,6 +55,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ t('security.activity.columns.time') }}</th>
|
<th>{{ t('security.activity.columns.time') }}</th>
|
||||||
|
<th>{{ t('security.activity.columns.source') }}</th>
|
||||||
<th>{{ t('security.activity.columns.user') }}</th>
|
<th>{{ t('security.activity.columns.user') }}</th>
|
||||||
<th>{{ t('security.activity.columns.action') }}</th>
|
<th>{{ t('security.activity.columns.action') }}</th>
|
||||||
<th>{{ t('security.activity.columns.resource') }}</th>
|
<th>{{ t('security.activity.columns.resource') }}</th>
|
||||||
@ -54,10 +64,13 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="event in events" :key="event.id">
|
<tr v-for="event in filteredEvents" :key="event.id" class="data-row" @click="openDetail(event)">
|
||||||
<td class="cell-time">{{ formatTime(event.createTime) }}</td>
|
<td class="cell-time">{{ formatTime(event.time || event.createTime) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="user-tag">{{ event.username }}</span>
|
<span class="source-pill" :class="`src-${event.source || 'audit'}`">{{ event.source || 'audit' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="user-tag">{{ event.username || '—' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="action-tag" :class="'action-' + event.action?.toLowerCase()">
|
<span class="action-tag" :class="'action-' + event.action?.toLowerCase()">
|
||||||
@ -76,6 +89,28 @@
|
|||||||
<div v-else-if="!events.length" class="empty-state">{{ t('security.activity.noEvents') }}</div>
|
<div v-else-if="!events.length" class="empty-state">{{ t('security.activity.noEvents') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- RFC-090 §4.5 detail drawer -->
|
||||||
|
<el-drawer
|
||||||
|
v-model="detailVisible"
|
||||||
|
:title="t('security.activity.detailTitle')"
|
||||||
|
direction="rtl"
|
||||||
|
size="520px"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
>
|
||||||
|
<div v-if="detailEvent" class="detail-pane">
|
||||||
|
<div class="detail-row"><span class="detail-key">{{ t('security.activity.columns.time') }}</span><span>{{ formatTime(detailEvent.time || detailEvent.createTime) }}</span></div>
|
||||||
|
<div class="detail-row"><span class="detail-key">{{ t('security.activity.columns.source') }}</span><span class="source-pill" :class="`src-${detailEvent.source || 'audit'}`">{{ detailEvent.source }}</span></div>
|
||||||
|
<div class="detail-row"><span class="detail-key">{{ t('security.activity.columns.user') }}</span><span>{{ detailEvent.username || '—' }}</span></div>
|
||||||
|
<div class="detail-row"><span class="detail-key">{{ t('security.activity.columns.action') }}</span><span class="action-tag" :class="'action-' + detailEvent.action?.toLowerCase()">{{ detailEvent.action }}</span></div>
|
||||||
|
<div class="detail-row"><span class="detail-key">{{ t('security.activity.columns.resource') }}</span><span>{{ detailEvent.resourceType }}: <code>{{ detailEvent.resourceName || detailEvent.resourceId || '—' }}</code></span></div>
|
||||||
|
<div class="detail-row" v-if="detailEvent.ipAddress"><span class="detail-key">{{ t('security.activity.columns.ip') }}</span><code>{{ detailEvent.ipAddress }}</code></div>
|
||||||
|
<div class="detail-section" v-if="detailEvent.detail">
|
||||||
|
<div class="detail-section-title">{{ t('security.activity.detailDetails') }}</div>
|
||||||
|
<pre class="detail-pre">{{ JSON.stringify(detailEvent.detail, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div v-if="total > pageSize" class="pagination">
|
<div v-if="total > pageSize" class="pagination">
|
||||||
<button class="btn-secondary btn-sm" :disabled="page <= 1" @click="page--; loadEvents()">«</button>
|
<button class="btn-secondary btn-sm" :disabled="page <= 1" @click="page--; loadEvents()">«</button>
|
||||||
@ -89,9 +124,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { auditApi } from '@/api'
|
import { activityApi } from '@/api'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@ -100,14 +135,27 @@ const loading = ref(false)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 20
|
const pageSize = 20
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const filters = reactive({ action: '', resourceType: '' })
|
// RFC-090 §4.5 — `source` filter goes to the server (audit / approval),
|
||||||
|
// `action` and `resourceType` filter locally so users can refine the
|
||||||
|
// merged feed without a roundtrip per change.
|
||||||
|
const filters = reactive({ source: '', action: '', resourceType: '' })
|
||||||
|
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailEvent = ref<any | null>(null)
|
||||||
|
|
||||||
|
const filteredEvents = computed(() => {
|
||||||
|
return events.value.filter(ev => {
|
||||||
|
if (filters.action && ev.action !== filters.action) return false
|
||||||
|
if (filters.resourceType && ev.resourceType !== filters.resourceType) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
async function loadEvents() {
|
async function loadEvents() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await auditApi.listEvents({
|
const res: any = await activityApi.feed({
|
||||||
action: filters.action || undefined,
|
source: filters.source || undefined,
|
||||||
resourceType: filters.resourceType || undefined,
|
|
||||||
page: page.value,
|
page: page.value,
|
||||||
size: pageSize,
|
size: pageSize,
|
||||||
})
|
})
|
||||||
@ -120,6 +168,15 @@ async function loadEvents() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterEventsLocally() {
|
||||||
|
// Local filter only — events array stays loaded; computed re-derives.
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(event: any) {
|
||||||
|
detailEvent.value = event
|
||||||
|
detailVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(dateStr: string) {
|
function formatTime(dateStr: string) {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
const d = new Date(dateStr)
|
const d = new Date(dateStr)
|
||||||
@ -204,4 +261,18 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-info { font-size: 13px; color: var(--mc-text-tertiary); }
|
.page-info { font-size: 13px; color: var(--mc-text-tertiary); }
|
||||||
|
|
||||||
|
/* RFC-090 §4.5 — source pill + clickable rows + detail drawer */
|
||||||
|
.source-pill { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.src-audit { background: var(--mc-primary-bg); color: var(--mc-primary); }
|
||||||
|
.src-approval { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
|
||||||
|
.data-row { cursor: pointer; }
|
||||||
|
.data-row:hover { background: var(--mc-bg-muted); }
|
||||||
|
.detail-pane { padding: 12px 16px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.detail-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--mc-border-light); font-size: 13px; }
|
||||||
|
.detail-row:last-child { border-bottom: none; }
|
||||||
|
.detail-key { width: 110px; flex-shrink: 0; color: var(--mc-text-tertiary); font-size: 12px; font-weight: 600; }
|
||||||
|
.detail-section { margin-top: 6px; }
|
||||||
|
.detail-section-title { font-size: 12px; font-weight: 700; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
|
||||||
|
.detail-pre { background: var(--mc-bg-sunken); padding: 12px; border-radius: 8px; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 11px; line-height: 1.5; max-height: 320px; overflow: auto; white-space: pre-wrap; word-break: break-word; margin: 0; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user