mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(activity): redesign Activity page (taste pass)
This commit is contained in:
parent
f8524bf122
commit
18c5c00797
@ -33,6 +33,8 @@ export default {
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
configure: 'Configure',
|
||||
refresh: 'Refresh',
|
||||
collapse: 'Collapse',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
},
|
||||
@ -799,17 +801,51 @@ export default {
|
||||
activity: 'Activity',
|
||||
},
|
||||
activity: {
|
||||
title: 'Activity Log',
|
||||
desc: 'Unified feed: audit events + tool approvals (and successful tool calls when persistence lands).',
|
||||
title: 'Activity',
|
||||
desc: 'Who did what, in plain English. Click any event to see the full story.',
|
||||
allActions: 'All Actions',
|
||||
allResources: 'All Resources',
|
||||
allSources: 'All Sources',
|
||||
allSources: 'All',
|
||||
sourceAudit: 'Audit',
|
||||
sourceApproval: 'Approval',
|
||||
sourceApproval: 'Approvals',
|
||||
detailTitle: 'Event detail',
|
||||
detailDetails: 'Detail',
|
||||
loading: 'Loading...',
|
||||
loading: 'Loading…',
|
||||
noEvents: 'No activity yet',
|
||||
emptyHint: 'As you create agents, bind skills, or approve tool calls, they show up here.',
|
||||
moreFilters: 'More filters',
|
||||
changes: 'Changes',
|
||||
environment: 'Environment',
|
||||
absoluteTime: 'Absolute time',
|
||||
noValue: '(empty)',
|
||||
rawPayload: 'Raw payload',
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
justNow: 'just now',
|
||||
minutesAgo: '{n} min ago',
|
||||
hoursAgo: '{n} h ago',
|
||||
daysAgo: '{n} d ago',
|
||||
verbs: {
|
||||
create: 'created',
|
||||
update: 'updated',
|
||||
delete: 'deleted',
|
||||
enable: 'enabled',
|
||||
disable: 'disabled',
|
||||
login: 'signed in',
|
||||
logout: 'signed out',
|
||||
approvalGranted: 'approved',
|
||||
approvalDenied: 'denied',
|
||||
approvalPending: 'requested',
|
||||
},
|
||||
resources: {
|
||||
agent: 'agent',
|
||||
channel: 'channel',
|
||||
skill: 'skill',
|
||||
wiki: 'wiki page',
|
||||
member: 'member',
|
||||
workspace: 'workspace',
|
||||
toolApproval: 'tool call',
|
||||
},
|
||||
columns: {
|
||||
time: 'Time',
|
||||
source: 'Source',
|
||||
|
||||
@ -35,6 +35,8 @@ export default {
|
||||
configure: '配置',
|
||||
enable: '启用',
|
||||
disable: '停用',
|
||||
refresh: '刷新',
|
||||
collapse: '收起',
|
||||
},
|
||||
auth: {
|
||||
changePassword: '修改密码',
|
||||
@ -799,17 +801,51 @@ export default {
|
||||
activity: '操作日志',
|
||||
},
|
||||
activity: {
|
||||
title: '操作日志',
|
||||
desc: '聚合视图:审计事件 + 工具审批(成功 tool 调用待落库后自动接入)。',
|
||||
title: '活动',
|
||||
desc: '谁做了什么,一句话讲清楚。点开任意一行看完整故事。',
|
||||
allActions: '全部操作',
|
||||
allResources: '全部资源',
|
||||
allSources: '全部来源',
|
||||
allSources: '全部',
|
||||
sourceAudit: '审计',
|
||||
sourceApproval: '审批',
|
||||
detailTitle: '事件详情',
|
||||
detailDetails: '详情',
|
||||
loading: '加载中...',
|
||||
loading: '加载中…',
|
||||
noEvents: '暂无操作记录',
|
||||
emptyHint: '创建智能体、绑定技能、审批工具调用都会出现在这里。',
|
||||
moreFilters: '更多筛选',
|
||||
changes: '改动',
|
||||
environment: '环境',
|
||||
absoluteTime: '绝对时间',
|
||||
noValue: '(空)',
|
||||
rawPayload: '原始数据',
|
||||
today: '今天',
|
||||
yesterday: '昨天',
|
||||
justNow: '刚刚',
|
||||
minutesAgo: '{n} 分钟前',
|
||||
hoursAgo: '{n} 小时前',
|
||||
daysAgo: '{n} 天前',
|
||||
verbs: {
|
||||
create: '创建了',
|
||||
update: '更新了',
|
||||
delete: '删除了',
|
||||
enable: '启用了',
|
||||
disable: '停用了',
|
||||
login: '登录了',
|
||||
logout: '退出了',
|
||||
approvalGranted: '批准了',
|
||||
approvalDenied: '拒绝了',
|
||||
approvalPending: '请求了',
|
||||
},
|
||||
resources: {
|
||||
agent: '智能体',
|
||||
channel: '频道',
|
||||
skill: '技能',
|
||||
wiki: 'Wiki 页',
|
||||
member: '成员',
|
||||
workspace: '工作区',
|
||||
toolApproval: '工具调用',
|
||||
},
|
||||
columns: {
|
||||
time: '时间',
|
||||
source: '来源',
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<!-- RFC-090 Phase 4: Activity 提升到顶层路由 /activity,但本组件
|
||||
既保留 settings-section 内层结构以便旧 Settings 引用兼容,
|
||||
又用 mc-page-shell 包一层在顶层路由下显示完整页框。 -->
|
||||
<!-- 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">
|
||||
@ -11,115 +11,187 @@
|
||||
<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>
|
||||
<div class="settings-section activity-inner mc-surface-card">
|
||||
<!-- Filters -->
|
||||
<div class="filter-row">
|
||||
<!-- 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="CREATE">CREATE</option>
|
||||
<option value="UPDATE">UPDATE</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
<option value="ENABLE">ENABLE</option>
|
||||
<option value="DISABLE">DISABLE</option>
|
||||
<option value="APPROVAL_GRANTED">APPROVAL_GRANTED</option>
|
||||
<option value="APPROVAL_DENIED">APPROVAL_DENIED</option>
|
||||
</select>
|
||||
<select v-model="filters.resourceType" class="filter-select" @change="filterEventsLocally">
|
||||
<option value="">{{ t('security.activity.allResources') }}</option>
|
||||
<option value="AGENT">Agent</option>
|
||||
<option value="CHANNEL">Channel</option>
|
||||
<option value="SKILL">Skill</option>
|
||||
<option value="WIKI">Wiki</option>
|
||||
<option value="MEMBER">Member</option>
|
||||
<option value="WORKSPACE">Workspace</option>
|
||||
<option value="TOOL_APPROVAL">Tool Approval</option>
|
||||
</select>
|
||||
<button class="btn-secondary" @click="loadEvents">
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > pageSize" class="pagination">
|
||||
<button class="btn-ghost" :disabled="page <= 1" @click="page--; loadEvents()">←</button>
|
||||
<span class="page-info">{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
|
||||
<button class="btn-ghost" :disabled="page >= Math.ceil(total / pageSize)" @click="page++; loadEvents()">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Timeline -->
|
||||
<div class="rules-table-wrapper">
|
||||
<table class="rules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<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.action') }}</th>
|
||||
<th>{{ t('security.activity.columns.resource') }}</th>
|
||||
<th>{{ t('security.activity.columns.name') }}</th>
|
||||
<th>{{ t('security.activity.columns.ip') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="event in filteredEvents" :key="event.id" class="data-row" @click="openDetail(event)">
|
||||
<td class="cell-time">{{ formatTime(event.time || event.createTime) }}</td>
|
||||
<td>
|
||||
<span class="source-pill" :class="`src-${event.source || 'audit'}`">{{ event.source || 'audit' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="user-tag">{{ event.username || '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="action-tag" :class="'action-' + event.action?.toLowerCase()">
|
||||
{{ event.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="resource-tag">{{ event.resourceType }}</span>
|
||||
</td>
|
||||
<td class="cell-name">{{ event.resourceName || event.resourceId || '-' }}</td>
|
||||
<td class="cell-ip">{{ event.ipAddress || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="loading" class="empty-state">{{ t('security.activity.loading') }}</div>
|
||||
<div v-else-if="!events.length" class="empty-state">{{ t('security.activity.noEvents') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- RFC-090 §4.5 detail drawer -->
|
||||
<!-- 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')"
|
||||
:show-close="true"
|
||||
:with-header="false"
|
||||
direction="rtl"
|
||||
size="520px"
|
||||
size="720px"
|
||||
: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 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>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > pageSize" class="pagination">
|
||||
<button class="btn-secondary btn-sm" :disabled="page <= 1" @click="page--; loadEvents()">«</button>
|
||||
<span class="page-info">{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
|
||||
<button class="btn-secondary btn-sm" :disabled="page >= Math.ceil(total / pageSize)" @click="page++; loadEvents()">»</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -130,18 +202,46 @@ import { activityApi } from '@/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const events = ref<any[]>([])
|
||||
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>
|
||||
}
|
||||
|
||||
const events = ref<ActivityEvent[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const pageSize = 30
|
||||
const total = ref(0)
|
||||
// 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 showMoreFilters = ref(false)
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailEvent = ref<any | null>(null)
|
||||
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 => {
|
||||
@ -151,6 +251,103 @@ const filteredEvents = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@ -168,19 +365,72 @@ async function loadEvents() {
|
||||
}
|
||||
}
|
||||
|
||||
function filterEventsLocally() {
|
||||
// Local filter only — events array stays loaded; computed re-derives.
|
||||
}
|
||||
function filterEventsLocally() { /* trigger recompute via reactive filter */ }
|
||||
|
||||
function openDetail(event: any) {
|
||||
function openDetail(event: ActivityEvent) {
|
||||
detailEvent.value = event
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleString()
|
||||
/** 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(() => {
|
||||
@ -188,91 +438,257 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import '../shared.css';
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
/* RFC-090 Phase 4 — top-level standalone shell */
|
||||
.activity-page { gap: 18px; }
|
||||
.activity-inner { padding: 18px; }
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
.activity-page { gap: 16px; display: flex; flex-direction: column; padding: 0; }
|
||||
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||
.btn-ghost {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px; border: 1px solid var(--mc-border-light);
|
||||
background: transparent; color: var(--mc-text-secondary);
|
||||
border-radius: 999px; font-size: 12px; font-weight: 500; cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) { background: var(--mc-bg-muted); color: var(--mc-text-primary); }
|
||||
.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Filter chips — minimal source toggle, one row */
|
||||
.filter-chips {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.filter-chip {
|
||||
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: all 0.15s;
|
||||
}
|
||||
.filter-chip:hover { border-color: var(--mc-border); color: var(--mc-text-primary); }
|
||||
.filter-chip.active {
|
||||
background: var(--mc-text-primary); color: var(--mc-bg-elevated);
|
||||
border-color: var(--mc-text-primary); font-weight: 600;
|
||||
}
|
||||
.chip-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.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;
|
||||
}
|
||||
.filter-more:hover { color: var(--mc-text-primary); }
|
||||
|
||||
.filter-extra { display: flex; gap: 8px; padding: 4px 0; }
|
||||
.filter-select {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 6px;
|
||||
background: var(--mc-bg);
|
||||
color: var(--mc-text-primary);
|
||||
font-size: 13px;
|
||||
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;
|
||||
}
|
||||
|
||||
.cell-time { font-size: 12px; color: var(--mc-text-tertiary); white-space: nowrap; }
|
||||
.cell-name { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cell-ip { font-size: 12px; color: var(--mc-text-tertiary); font-family: 'SF Mono', monospace; }
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s, max-height 0.2s; max-height: 60px; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; max-height: 0; }
|
||||
|
||||
.user-tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
/* Event feed — flat cards, day grouped */
|
||||
.event-feed { display: flex; flex-direction: column; }
|
||||
|
||||
.day-divider {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px 4px 6px;
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.day-label::after { content: ''; }
|
||||
.day-count {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
background: var(--mc-bg-elevated);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
margin-bottom: 6px;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.event-row:hover {
|
||||
border-color: var(--mc-border);
|
||||
background: var(--mc-bg-muted);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--mc-shadow-soft);
|
||||
}
|
||||
|
||||
.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;
|
||||
font-weight: 500;
|
||||
background: var(--mc-bg-sunken);
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
|
||||
.action-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
.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;
|
||||
}
|
||||
|
||||
.action-create { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||
.action-update { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
|
||||
.action-delete { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
.action-enable { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||
.action-disable { background: rgba(245, 158, 11, 0.12); color: #f59e0b; }
|
||||
.action-login { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
|
||||
.action-logout { background: rgba(107, 114, 128, 0.12); color: #6b7280; }
|
||||
|
||||
.resource-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
background: var(--mc-bg-sunken);
|
||||
color: var(--mc-text-secondary);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
.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: 8px;
|
||||
padding: 60px 20px;
|
||||
color: var(--mc-text-tertiary); font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.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;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 14px; margin-top: 18px;
|
||||
}
|
||||
.page-info { font-size: 12px; color: var(--mc-text-tertiary); }
|
||||
|
||||
/* Detail drawer — wider, no internal max-height frame */
|
||||
.detail-shell {
|
||||
padding: 24px 28px 32px;
|
||||
display: flex; flex-direction: column; gap: 22px;
|
||||
}
|
||||
|
||||
.page-info { font-size: 13px; color: var(--mc-text-tertiary); }
|
||||
.detail-hero {
|
||||
position: relative;
|
||||
padding: 4px 0 18px;
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.detail-dot {
|
||||
position: absolute; left: -16px; top: 16px;
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
}
|
||||
.detail-headline {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px; font-weight: 700; line-height: 1.3;
|
||||
color: var(--mc-text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.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; }
|
||||
|
||||
/* 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; }
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user