feat(activity): three-source merged feed + detail drawer

This commit is contained in:
matevip 2026-05-01 09:50:11 +08:00
parent 170cb1f2f2
commit b8ce36f6cd
5 changed files with 268 additions and 13 deletions

View File

@ -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
) {}
}

View File

@ -177,6 +177,12 @@ export const skillApi = {
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) ====================
export const acpApi = {
list: () => http.get('/acp/endpoints'),

View File

@ -800,13 +800,19 @@ export default {
},
activity: {
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',
allResources: 'All Resources',
allSources: 'All Sources',
sourceAudit: 'Audit',
sourceApproval: 'Approval',
detailTitle: 'Event detail',
detailDetails: 'Detail',
loading: 'Loading...',
noEvents: 'No activity yet',
columns: {
time: 'Time',
source: 'Source',
user: 'User',
action: 'Action',
resource: 'Resource',

View File

@ -800,13 +800,19 @@ export default {
},
activity: {
title: '操作日志',
desc: '查看工作区内所有资源的创建、修改、删除操作记录。',
desc: '聚合视图:审计事件 + 工具审批(成功 tool 调用待落库后自动接入)。',
allActions: '全部操作',
allResources: '全部资源',
allSources: '全部来源',
sourceAudit: '审计',
sourceApproval: '审批',
detailTitle: '事件详情',
detailDetails: '详情',
loading: '加载中...',
noEvents: '暂无操作记录',
columns: {
time: '时间',
source: '来源',
user: '用户',
action: '操作',
resource: '资源类型',

View File

@ -15,15 +15,23 @@
<div class="settings-section activity-inner mc-surface-card">
<!-- Filters -->
<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="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="loadEvents">
<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>
@ -31,6 +39,7 @@
<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">
@ -46,6 +55,7 @@
<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>
@ -54,10 +64,13 @@
</tr>
</thead>
<tbody>
<tr v-for="event in events" :key="event.id">
<td class="cell-time">{{ formatTime(event.createTime) }}</td>
<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="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>
<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>
<!-- 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 -->
<div v-if="total > pageSize" class="pagination">
<button class="btn-secondary btn-sm" :disabled="page <= 1" @click="page--; loadEvents()">&laquo;</button>
@ -89,9 +124,9 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { auditApi } from '@/api'
import { activityApi } from '@/api'
const { t } = useI18n()
@ -100,14 +135,27 @@ const loading = ref(false)
const page = ref(1)
const pageSize = 20
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() {
loading.value = true
try {
const res: any = await auditApi.listEvents({
action: filters.action || undefined,
resourceType: filters.resourceType || undefined,
const res: any = await activityApi.feed({
source: filters.source || undefined,
page: page.value,
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) {
if (!dateStr) return '-'
const d = new Date(dateStr)
@ -204,4 +261,18 @@ onMounted(() => {
}
.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>