mateclaw/mateclaw-ui/src/views/Security/Activity/index.vue

885 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<!-- RFC-090 Phase 4 Jobs-style rewrite. Activity tells you who
did what, in plain English. The old 7-column table dumped raw
fields; the new layout reads like a feed. -->
<div class="mc-page-shell">
<div class="mc-page-frame">
<div class="mc-page-inner activity-page">
<div class="mc-page-header">
<div>
<div class="mc-page-kicker">{{ t('nav.activity') }}</div>
<h1 class="mc-page-title">{{ t('security.activity.title') }}</h1>
<p class="mc-page-desc">{{ t('security.activity.desc') }}</p>
</div>
<div class="header-actions">
<button class="btn-ghost" @click="loadEvents" :disabled="loading">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
{{ loading ? t('security.activity.loading') : t('common.refresh') }}
</button>
</div>
</div>
<!-- Filter chips minimal, source-only by default; extra
filters tucked behind a kebab so the page stays uncluttered -->
<div class="filter-chips">
<button
v-for="src in sourceChips"
:key="src.value"
class="filter-chip"
:class="{ active: filters.source === src.value }"
@click="setSourceFilter(src.value)"
>
<span v-if="src.dot" class="chip-dot" :class="`dot-${src.value || 'all'}`"></span>
{{ src.label }}
</button>
<div class="filter-spacer"></div>
<button class="filter-more" @click="showMoreFilters = !showMoreFilters">
{{ showMoreFilters ? t('common.collapse') : t('security.activity.moreFilters') }}
</button>
</div>
<transition name="fade">
<div v-if="showMoreFilters" class="filter-extra">
<select v-model="filters.action" class="filter-select" @change="filterEventsLocally">
<option value="">{{ t('security.activity.allActions') }}</option>
<option v-for="a in knownActions" :key="a" :value="a">{{ a }}</option>
</select>
<select v-model="filters.resourceType" class="filter-select" @change="filterEventsLocally">
<option value="">{{ t('security.activity.allResources') }}</option>
<option v-for="r in knownResources" :key="r" :value="r">{{ r }}</option>
</select>
</div>
</transition>
<!-- Event feed grouped by day, each event is one sentence -->
<div v-if="loading && groupedEvents.length === 0" class="feed-empty">
{{ t('security.activity.loading') }}
</div>
<div v-else-if="groupedEvents.length === 0" class="feed-empty">
<div class="empty-icon">📋</div>
<p>{{ t('security.activity.noEvents') }}</p>
<p class="empty-hint">{{ t('security.activity.emptyHint') }}</p>
</div>
<div v-else class="event-feed">
<template v-for="group in groupedEvents" :key="group.label">
<div class="day-divider">
<span class="day-label">{{ group.label }}</span>
<span class="day-count">{{ group.events.length }}</span>
</div>
<button
v-for="event in group.events"
:key="event.id"
class="event-row"
@click="openDetail(event)"
>
<span class="event-dot" :class="`dot-${event.source || 'audit'}`"></span>
<div class="event-body">
<div class="event-sentence">
<span class="event-actor">{{ event.username || '—' }}</span>
<span class="event-verb" :class="`verb-${(event.action || '').toLowerCase()}`">
{{ verbFor(event) }}
</span>
<span class="event-target">
<span class="target-type">{{ resourceLabel(event) }}</span>
<span v-if="event.resourceName || event.resourceId" class="target-name">
{{ event.resourceName || event.resourceId }}
</span>
</span>
</div>
<div v-if="event.summary" class="event-summary">{{ event.summary }}</div>
</div>
<span class="event-time" :title="absoluteTime(event)">{{ relativeTime(event) }}</span>
<span class="event-chevron"></span>
</button>
</template>
</div>
<!-- MateClaw frosted-pill pagination — replaces el-pagination
so the activity feed reads in the same visual language as
the rest of the app. -->
<div v-if="total > pageSize" class="pagination">
<McPagination
v-model:page="page"
v-model:size="pageSize"
:total="total"
:sizes="[20, 50, 100]"
@change="loadEvents"
/>
</div>
</div>
</div>
<!-- Detail drawer — wider (720px), no internal max-height frame.
Hero block at the top so the user knows in 0.5s what happened.
JSON dump replaced by structured field rendering. -->
<el-drawer
v-model="detailVisible"
:title="t('security.activity.detailTitle')"
direction="rtl"
:size="drawerSize"
:destroy-on-close="true"
>
<div v-if="detailEvent" class="detail-shell">
<div class="detail-hero" :class="`hero-${detailEvent.source || 'audit'}`">
<span class="detail-dot" :class="`dot-${detailEvent.source || 'audit'}`"></span>
<h2 class="detail-headline">
<span class="detail-actor">{{ detailEvent.username || '—' }}</span>
<span class="detail-verb" :class="`verb-${(detailEvent.action || '').toLowerCase()}`">
{{ verbFor(detailEvent) }}
</span>
<span class="detail-target-type">{{ resourceLabel(detailEvent) }}</span>
</h2>
<div v-if="detailEvent.resourceName || detailEvent.resourceId" class="detail-target-name">
{{ detailEvent.resourceName || detailEvent.resourceId }}
</div>
<div class="detail-meta">
<span class="meta-time">{{ relativeTime(detailEvent) }}</span>
<span class="meta-sep">·</span>
<span class="meta-source-label">{{ sourceLabel(detailEvent) }}</span>
</div>
</div>
<!-- Changes block — structured rendering of the detail map -->
<section v-if="changeEntries.length > 0" class="detail-block">
<h3 class="detail-block-title">{{ t('security.activity.changes') }}</h3>
<div class="change-list">
<div v-for="c in changeEntries" :key="c.key" class="change-item">
<span class="change-key">{{ c.key }}</span>
<span class="change-value" :class="{ 'is-empty': c.empty }">
<template v-if="c.empty">{{ t('security.activity.noValue') }}</template>
<template v-else-if="c.code"><code>{{ c.value }}</code></template>
<template v-else>{{ c.value }}</template>
</span>
</div>
</div>
</section>
<!-- Environment block — IP, user-agent, absolute time -->
<section class="detail-block">
<h3 class="detail-block-title">{{ t('security.activity.environment') }}</h3>
<div class="change-list">
<div class="change-item">
<span class="change-key">{{ t('security.activity.absoluteTime') }}</span>
<span class="change-value"><code>{{ absoluteTime(detailEvent) }}</code></span>
</div>
<div class="change-item">
<span class="change-key">{{ t('security.activity.columns.user') }}</span>
<span class="change-value">{{ detailEvent.username || '—' }}</span>
</div>
<div v-if="detailEvent.ipAddress" class="change-item">
<span class="change-key">{{ t('security.activity.columns.ip') }}</span>
<span class="change-value"><code>{{ detailEvent.ipAddress }}</code></span>
</div>
<div class="change-item">
<span class="change-key">{{ t('security.activity.columns.action') }}</span>
<span class="change-value"><code>{{ detailEvent.action || '—' }}</code></span>
</div>
<div class="change-item">
<span class="change-key">{{ t('security.activity.columns.resource') }}</span>
<span class="change-value">
<code>{{ detailEvent.resourceType }}</code>
<span v-if="detailEvent.resourceName || detailEvent.resourceId">
· {{ detailEvent.resourceName || detailEvent.resourceId }}
</span>
</span>
</div>
</div>
</section>
<!-- Raw payload, collapsed — power users only -->
<details v-if="rawPayload" class="detail-raw">
<summary>{{ t('security.activity.rawPayload') }}</summary>
<pre class="detail-pre">{{ rawPayload }}</pre>
</details>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { activityApi } from '@/api'
import McPagination from '@/components/common/McPagination.vue'
const { t } = useI18n()
interface ActivityEvent {
id: string
source?: string
time?: string
createTime?: string
username?: string
action?: string
resourceType?: string
resourceName?: string
resourceId?: string
ipAddress?: string
summary?: string
detail?: Record<string, any>
}
/**
* Mobile detection — drives the el-drawer size and el-pagination
* layout dynamically. CSS @media handles static layout, but EP
* component props need a reactive value to switch from a 720px
* drawer to full-width sheet, and to drop the size-switcher /
* jumper when there's no horizontal room for them.
*/
const isMobile = ref(false)
let mq: MediaQueryList | null = null
function syncMobile(e: MediaQueryListEvent | MediaQueryList) {
isMobile.value = e.matches
}
const drawerSize = computed(() => isMobile.value ? '100%' : '720px')
const events = ref<ActivityEvent[]>([])
const loading = ref(false)
const page = ref(1)
// Element Plus pagination owns this ref; @size-change writes through.
// Default 20 to match the smallest entry in :page-sizes — anything
// off-list leaves the size dropdown showing a blank current value.
const pageSize = ref(20)
const total = ref(0)
const filters = reactive({ source: '', action: '', resourceType: '' })
const showMoreFilters = ref(false)
const detailVisible = ref(false)
const detailEvent = ref<ActivityEvent | null>(null)
const sourceChips = computed(() => [
{ value: '', label: t('security.activity.allSources'), dot: false },
{ value: 'audit', label: t('security.activity.sourceAudit'), dot: true },
{ value: 'approval', label: t('security.activity.sourceApproval'), dot: true },
])
const knownActions = [
'CREATE', 'UPDATE', 'DELETE', 'ENABLE', 'DISABLE',
'LOGIN', 'LOGOUT',
'APPROVAL_GRANTED', 'APPROVAL_DENIED', 'APPROVAL_PENDING',
]
const knownResources = [
'AGENT', 'CHANNEL', 'SKILL', 'WIKI', 'MEMBER', 'WORKSPACE', 'TOOL_APPROVAL',
]
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
})
})
/**
* RFC-090 §4.5 — group events by relative day so the eye gets a
* rhythm (Today / Yesterday / weekday) instead of a flat 30-row
* timestamp soup.
*/
const groupedEvents = computed(() => {
const groups: { label: string; events: ActivityEvent[] }[] = []
const today = startOfDay(new Date())
const yesterday = new Date(today.getTime() - 86_400_000)
const weekAgo = new Date(today.getTime() - 7 * 86_400_000)
function bucket(d: Date): string {
const day = startOfDay(d)
if (day.getTime() === today.getTime()) return t('security.activity.today')
if (day.getTime() === yesterday.getTime()) return t('security.activity.yesterday')
if (day.getTime() > weekAgo.getTime()) {
return d.toLocaleDateString(undefined, { weekday: 'long' })
}
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
let lastLabel = ''
for (const ev of filteredEvents.value) {
const ts = ev.time || ev.createTime
if (!ts) continue
const d = new Date(ts)
const label = bucket(d)
if (label !== lastLabel) {
groups.push({ label, events: [] })
lastLabel = label
}
groups[groups.length - 1].events.push(ev)
}
return groups
})
/** Decompose detail map into structured [{key, value, code, empty}] rows
* while filtering out fields that are already in the hero block (action,
* resource, time) and string-encoded JSON noise from the audit pipeline. */
const changeEntries = computed<Array<{ key: string; value: string; code: boolean; empty: boolean }>>(() => {
const ev = detailEvent.value
if (!ev || !ev.detail) return []
const skipKeys = new Set([
'workspaceId', 'userAgent', 'detailJson',
'time', 'createTime', 'action', 'resourceType', 'resourceName', 'resourceId',
'username', 'ipAddress',
])
const out: Array<{ key: string; value: string; code: boolean; empty: boolean }> = []
// The audit pipeline historically nests JSON inside a string-typed
// `detailJson` field. Try to expand it so the change list stays
// first-class structured, not "open the raw payload to read".
const merged: Record<string, any> = { ...ev.detail }
const nested = ev.detail.detailJson
if (typeof nested === 'string' && nested.trim().startsWith('{')) {
try {
const parsed = JSON.parse(nested)
Object.assign(merged, parsed)
} catch { /* leave detailJson as-is */ }
}
for (const [key, raw] of Object.entries(merged)) {
if (skipKeys.has(key)) continue
if (raw === undefined || raw === null || raw === '') {
out.push({ key, value: '', code: false, empty: true })
continue
}
if (typeof raw === 'object') {
out.push({ key, value: JSON.stringify(raw), code: true, empty: false })
} else {
const s = String(raw)
const code = s.length < 80 && (s.startsWith('{') || s.startsWith('[') || /^[\w./:_-]+$/.test(s))
out.push({ key, value: s, code, empty: false })
}
}
return out
})
/** Power-user payload (only when there's actually a detail map). */
const rawPayload = computed(() => {
const ev = detailEvent.value
if (!ev || !ev.detail) return ''
return JSON.stringify(ev.detail, null, 2)
})
function startOfDay(d: Date): Date {
const x = new Date(d)
x.setHours(0, 0, 0, 0)
return x
}
function setSourceFilter(value: string) {
filters.source = value
page.value = 1
loadEvents()
}
async function loadEvents() {
loading.value = true
try {
const res: any = await activityApi.feed({
source: filters.source || undefined,
page: page.value,
size: pageSize.value,
})
events.value = res.data?.records || []
// Force Number coercion — backend sometimes serializes Long as
// string (Jackson big-number safety), and 'total > pageSize'
// would fall back to string comparison ('30' > 20 → true by
// accident, '5' > 20 → also true!). Keep arithmetic numeric.
total.value = Number(res.data?.total ?? 0) || 0
} catch {
events.value = []
} finally {
loading.value = false
}
}
function filterEventsLocally() { /* trigger recompute via reactive filter */ }
function openDetail(event: ActivityEvent) {
detailEvent.value = event
detailVisible.value = true
}
/** Natural-language verb. Audit actions get a clean past-tense translation;
* approval actions get domain-appropriate verbs. Falls back to the raw
* action so future enums don't silently render as "—". */
function verbFor(event: ActivityEvent): string {
const a = (event.action || '').toUpperCase()
const map: Record<string, string> = {
CREATE: t('security.activity.verbs.create'),
UPDATE: t('security.activity.verbs.update'),
DELETE: t('security.activity.verbs.delete'),
ENABLE: t('security.activity.verbs.enable'),
DISABLE: t('security.activity.verbs.disable'),
LOGIN: t('security.activity.verbs.login'),
LOGOUT: t('security.activity.verbs.logout'),
APPROVAL_GRANTED: t('security.activity.verbs.approvalGranted'),
APPROVAL_DENIED: t('security.activity.verbs.approvalDenied'),
APPROVAL_PENDING: t('security.activity.verbs.approvalPending'),
}
return map[a] || (event.action || '—')
}
function resourceLabel(event: ActivityEvent): string {
const r = (event.resourceType || '').toUpperCase()
const map: Record<string, string> = {
AGENT: t('security.activity.resources.agent'),
CHANNEL: t('security.activity.resources.channel'),
SKILL: t('security.activity.resources.skill'),
WIKI: t('security.activity.resources.wiki'),
MEMBER: t('security.activity.resources.member'),
WORKSPACE: t('security.activity.resources.workspace'),
TOOL_APPROVAL: t('security.activity.resources.toolApproval'),
}
return map[r] || (event.resourceType || '')
}
function sourceLabel(event: ActivityEvent): string {
if (event.source === 'approval') return t('security.activity.sourceApproval')
if (event.source === 'audit') return t('security.activity.sourceAudit')
return event.source || ''
}
function relativeTime(event: ActivityEvent): string {
const ts = event.time || event.createTime
if (!ts) return '—'
const now = Date.now()
const then = new Date(ts).getTime()
const diffSec = Math.max(0, Math.floor((now - then) / 1000))
if (diffSec < 60) return t('security.activity.justNow')
const diffMin = Math.floor(diffSec / 60)
if (diffMin < 60) return t('security.activity.minutesAgo', { n: diffMin })
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return t('security.activity.hoursAgo', { n: diffHr })
const diffDay = Math.floor(diffHr / 24)
if (diffDay < 7) return t('security.activity.daysAgo', { n: diffDay })
return new Date(ts).toLocaleDateString()
}
function absoluteTime(event: ActivityEvent): string {
const ts = event.time || event.createTime
return ts ? new Date(ts).toLocaleString() : '—'
}
onMounted(() => {
mq = window.matchMedia('(max-width: 768px)')
syncMobile(mq)
mq.addEventListener('change', syncMobile)
loadEvents()
})
onBeforeUnmount(() => {
mq?.removeEventListener('change', syncMobile)
})
</script>
<style scoped>
/* Layout — sit inside mc-page-inner's 28px padding; just stack our
own sections with breathing room. The earlier `padding: 0` override
collapsed the inset and let cards bleed into the frame border. */
.activity-page { gap: 18px; display: flex; flex-direction: column; }
.mc-page-header { margin-bottom: 6px; }
.header-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.btn-ghost {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 12px; border: 1px solid var(--mc-border-light);
background: var(--mc-bg-elevated); color: var(--mc-text-secondary);
border-radius: 999px; font-size: 12px; font-weight: 500; cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
font-family: inherit;
}
.btn-ghost:hover:not(:disabled) { background: var(--mc-bg-muted); color: var(--mc-text-primary); border-color: var(--mc-border); }
.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
/* Filter bar — frosted glass surface */
.filter-chips {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 10px 14px;
border: 1px solid var(--mc-border-light);
border-radius: 14px;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(14px) saturate(1.1);
-webkit-backdrop-filter: blur(14px) saturate(1.1);
}
:root.dark .filter-chips {
background: rgba(34, 26, 22, 0.55);
}
.filter-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 12px; border: 1px solid var(--mc-border-light);
background: rgba(255, 255, 255, 0.7); color: var(--mc-text-secondary);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 999px; font-size: 12px; font-weight: 500; cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
font-family: inherit;
}
:root.dark .filter-chip { background: rgba(42, 32, 26, 0.6); }
.filter-chip:hover { border-color: var(--mc-border); color: var(--mc-text-primary); }
/* Active chip — brand-coloured, glass-tinted */
.filter-chip.active {
background: var(--mc-primary-bg);
color: var(--mc-primary);
border-color: var(--mc-primary);
font-weight: 600;
}
.chip-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-all { background: var(--mc-text-tertiary); }
.dot-audit { background: #f59e0b; }
.dot-approval { background: #6366f1; }
.dot-tool { background: #10b981; }
.filter-spacer { flex: 1; }
.filter-more {
background: none; border: none; color: var(--mc-text-tertiary);
font-size: 12px; cursor: pointer; padding: 4px 8px;
font-family: inherit;
}
.filter-more:hover { color: var(--mc-text-primary); }
.filter-extra { display: flex; gap: 8px; flex-wrap: wrap; }
.filter-select {
padding: 6px 10px; border: 1px solid var(--mc-border-light);
border-radius: 8px; background: var(--mc-bg-elevated);
color: var(--mc-text-primary); font-size: 12px;
font-family: inherit;
min-width: 140px;
}
/* Fade transition — drop the brittle max-height clamp; rely on opacity
only. The earlier 60px ceiling truncated the dropdown row when both
selects wrapped. */
.fade-enter-active, .fade-leave-active { transition: opacity 0.18s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* Event feed — vertical stack, each row a self-contained card */
.event-feed { display: flex; flex-direction: column; gap: 0; }
.day-divider {
display: flex; align-items: center; gap: 10px;
padding: 18px 2px 8px;
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--mc-text-tertiary);
}
.day-divider:first-child { padding-top: 4px; }
.day-label { display: inline-block; }
.day-count {
display: inline-block;
padding: 1px 8px; border-radius: 999px;
background: var(--mc-bg-sunken); color: var(--mc-text-tertiary);
font-size: 10px; font-weight: 600; letter-spacing: 0;
text-transform: none;
}
.event-row {
width: 100%;
display: flex; align-items: flex-start; gap: 12px;
padding: 14px 16px;
border: 1px solid var(--mc-border-light);
border-radius: 12px;
/* Frosted glass: semi-transparent surface + blur of whatever sits
behind. Cheaper than a full opaque card and visually cohesive
with the parent mc-page-frame which already uses backdrop-filter. */
background: rgba(255, 255, 255, 0.62);
backdrop-filter: blur(12px) saturate(1.08);
-webkit-backdrop-filter: blur(12px) saturate(1.08);
cursor: pointer;
text-align: left;
transition: background 0.18s, border-color 0.18s, box-shadow 0.18s;
margin-bottom: 8px;
font-family: inherit;
font-size: inherit;
color: inherit;
}
:root.dark .event-row { background: rgba(42, 32, 26, 0.55); }
.event-row:last-child { margin-bottom: 0; }
.event-row:hover {
border-color: var(--mc-border);
background: rgba(255, 255, 255, 0.85);
box-shadow: var(--mc-shadow-soft);
}
:root.dark .event-row:hover { background: rgba(56, 42, 34, 0.7); }
/* Don't translateY on hover — the parent mc-page-frame has rounded
corners + a glow ::before overlay; lifted cards visibly poked
through the rounded border. */
.event-dot {
width: 8px; height: 8px; border-radius: 50%;
margin-top: 7px; flex-shrink: 0;
}
.event-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.event-sentence {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
font-size: 14px; line-height: 1.5;
color: var(--mc-text-primary);
}
.event-actor { font-weight: 600; color: var(--mc-text-primary); }
.event-verb {
font-weight: 500; padding: 1px 8px; border-radius: 6px;
font-size: 12px;
}
.event-target { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.target-type { color: var(--mc-text-tertiary); font-size: 13px; }
.target-name {
color: var(--mc-text-primary); font-weight: 500;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
overflow: hidden; text-overflow: ellipsis; max-width: 320px; white-space: nowrap;
}
.event-summary {
font-size: 12px; color: var(--mc-text-tertiary);
line-height: 1.5;
}
.event-time {
font-size: 12px; color: var(--mc-text-tertiary);
white-space: nowrap; flex-shrink: 0; padding-top: 2px;
}
.event-chevron {
color: var(--mc-text-tertiary); font-size: 18px; line-height: 1;
padding-top: 0; flex-shrink: 0; opacity: 0;
transition: opacity 0.15s, transform 0.15s;
}
.event-row:hover .event-chevron { opacity: 1; transform: translateX(2px); }
/* Verb tinting — green = create/grant, red = delete/deny, etc. */
.verb-create, .verb-enable, .verb-approval_granted { color: #16a34a; background: rgba(34, 197, 94, 0.10); }
.verb-update { color: #2563eb; background: rgba(59, 130, 246, 0.10); }
.verb-delete, .verb-approval_denied { color: #dc2626; background: rgba(239, 68, 68, 0.10); }
.verb-disable, .verb-approval_pending { color: #d97706; background: rgba(245, 158, 11, 0.10); }
.verb-login, .verb-logout { color: #7c3aed; background: rgba(139, 92, 246, 0.10); }
:root.dark .verb-create, :root.dark .verb-enable, :root.dark .verb-approval_granted { color: #4ade80; background: rgba(34, 197, 94, 0.18); }
:root.dark .verb-update { color: #60a5fa; background: rgba(59, 130, 246, 0.18); }
:root.dark .verb-delete, :root.dark .verb-approval_denied { color: #fca5a5; background: rgba(239, 68, 68, 0.18); }
:root.dark .verb-disable, :root.dark .verb-approval_pending { color: #fbbf24; background: rgba(245, 158, 11, 0.18); }
:root.dark .verb-login, :root.dark .verb-logout { color: #c4b5fd; background: rgba(139, 92, 246, 0.18); }
.feed-empty {
display: flex; flex-direction: column; align-items: center; gap: 10px;
padding: 60px 20px;
color: var(--mc-text-tertiary); font-size: 14px;
text-align: center;
border: 1px dashed var(--mc-border-light);
border-radius: 12px;
background: var(--mc-bg-muted);
}
.feed-empty p { margin: 0; }
.empty-icon { font-size: 32px; }
.empty-hint { font-size: 12px; max-width: 320px; line-height: 1.6; }
.pagination {
display: flex; align-items: center; justify-content: center;
margin-top: 14px;
padding: 8px 14px;
border: 1px solid var(--mc-border-light);
border-radius: 14px;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(14px) saturate(1.1);
-webkit-backdrop-filter: blur(14px) saturate(1.1);
}
:root.dark .pagination { background: rgba(34, 26, 22, 0.55); }
.page-info { font-size: 12px; color: var(--mc-text-tertiary); }
/* Element Plus pagination — keep EP defaults for buttons/numbers so
they stay readable; only inherit our font and tint the active page
with the brand color. The earlier overrides set button text to
secondary-grey on a translucent white pill, which against the
glass container effectively rendered numbers invisible. */
.pagination :deep(.el-pagination) {
font-family: inherit;
--el-color-primary: var(--mc-primary);
}
/* Detail drawer — wider, no internal max-height frame */
.detail-shell {
padding: 24px 28px 32px;
display: flex; flex-direction: column; gap: 22px;
}
.detail-hero {
/* Glass hero block. No negative margin — el-drawer body has
overflow constraints and a -8px outdent caused the rounded
border to clip on the sides. */
position: relative;
padding: 18px 18px 22px;
border-radius: 16px;
border: 1px solid var(--mc-border-light);
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(16px) saturate(1.15);
-webkit-backdrop-filter: blur(16px) saturate(1.15);
}
:root.dark .detail-hero { background: rgba(42, 32, 26, 0.55); }
.detail-dot {
position: absolute; left: 18px; top: 24px;
width: 10px; height: 10px; border-radius: 50%;
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.4);
}
:root.dark .detail-dot { box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.08); }
.detail-headline {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: 0 0 6px;
padding-left: 22px; /* leave room for the dot at left: 18px */
font-size: 22px; font-weight: 700; line-height: 1.3;
color: var(--mc-text-primary);
letter-spacing: -0.01em;
}
.detail-target-name { margin-left: 22px; }
.detail-meta { padding-left: 22px; }
.detail-actor { font-weight: 700; }
.detail-verb {
font-size: 16px; font-weight: 600;
padding: 2px 10px; border-radius: 8px;
}
.detail-target-type { color: var(--mc-text-secondary); font-weight: 500; font-size: 18px; }
.detail-target-name {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 15px; color: var(--mc-text-primary);
background: var(--mc-bg-sunken);
padding: 4px 10px; border-radius: 6px;
display: inline-block; margin-top: 4px;
word-break: break-all;
}
.detail-meta {
display: flex; align-items: center; gap: 8px;
margin-top: 10px;
font-size: 12px; color: var(--mc-text-tertiary);
}
.meta-sep { opacity: 0.5; }
.detail-block { display: flex; flex-direction: column; gap: 10px; }
.detail-block-title {
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--mc-text-tertiary);
margin: 0;
}
.change-list { display: flex; flex-direction: column; gap: 0; border-top: 1px solid var(--mc-border-light); }
.change-item {
display: flex; align-items: flex-start; gap: 16px;
padding: 10px 0;
border-bottom: 1px solid var(--mc-border-light);
font-size: 13px;
line-height: 1.5;
}
.change-item:last-child { border-bottom: none; }
.change-key {
width: 140px; flex-shrink: 0;
color: var(--mc-text-tertiary); font-size: 12px; font-weight: 600;
}
.change-value {
flex: 1; min-width: 0;
color: var(--mc-text-primary);
word-break: break-word;
}
.change-value code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
background: var(--mc-bg-sunken);
padding: 2px 6px; border-radius: 4px;
}
.change-value.is-empty { color: var(--mc-text-tertiary); font-style: italic; }
.detail-raw {
margin-top: 4px;
border-top: 1px solid var(--mc-border-light);
padding-top: 14px;
}
.detail-raw summary {
cursor: pointer; font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--mc-text-tertiary);
padding: 4px 0;
}
.detail-raw summary:hover { color: var(--mc-text-secondary); }
.detail-pre {
margin-top: 8px;
background: var(--mc-bg-sunken);
padding: 14px; border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; line-height: 1.6;
color: var(--mc-text-secondary);
white-space: pre-wrap; word-break: break-word;
}
/* ============ Mobile / narrow viewport (≤ 768px) ============ */
@media (max-width: 768px) {
/* Header — stack title and refresh button vertically */
.activity-page :deep(.mc-page-header) {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.header-actions { justify-content: flex-end; }
/* Filter chips — drop the "More filters" link out of one row */
.filter-chips { padding: 8px 10px; gap: 6px; }
.filter-chip { padding: 5px 10px; font-size: 11px; }
.filter-spacer { flex-basis: 100%; height: 0; }
/* Event row — let target wrap naturally; time on a second line */
.event-row {
padding: 12px 14px;
flex-wrap: wrap;
gap: 10px;
}
.event-body { flex: 1 1 100%; min-width: 0; }
.event-sentence {
font-size: 13px;
line-height: 1.55;
}
.target-name {
max-width: 100%;
white-space: normal;
word-break: break-all;
}
.event-time {
/* Push time to the right edge of its own row under the body */
flex: 0 0 auto;
margin-left: auto;
font-size: 11px;
padding-top: 0;
}
.event-chevron { display: none; }
.event-dot { margin-top: 5px; }
/* Day divider — tighter rhythm on mobile */
.day-divider { padding: 14px 2px 6px; }
/* Detail drawer — drawer goes full-width via :size binding;
here we collapse the inner padding and headline scale. */
.detail-shell { padding: 16px 16px 24px; gap: 18px; }
.detail-hero { padding: 14px 14px 18px; }
.detail-dot { left: 14px; top: 20px; }
.detail-headline {
font-size: 18px;
padding-left: 18px;
gap: 6px;
}
.detail-verb { font-size: 13px; padding: 1px 8px; }
.detail-target-type { font-size: 14px; }
.detail-target-name {
font-size: 13px;
margin-left: 18px;
word-break: break-all;
}
.detail-meta { padding-left: 18px; flex-wrap: wrap; }
.change-key { width: 100px; font-size: 11px; }
.change-item { gap: 10px; padding: 8px 0; font-size: 12px; }
/* Pagination — :small=true on EP shrinks; container loosens padding */
.pagination { padding: 6px 8px; margin-top: 10px; }
}
/* ============ Very small phones (≤ 480px) ============ */
@media (max-width: 480px) {
.filter-chips { padding: 6px 8px; }
.event-row { padding: 10px 12px; }
.event-sentence { font-size: 12px; gap: 4px; }
.event-actor { font-weight: 700; }
.target-name { font-size: 12px; }
.detail-headline { font-size: 16px; }
.detail-target-name { font-size: 12px; }
.change-key { width: 84px; }
}
</style>