feat(activity): real pagination + frosted-glass styling

This commit is contained in:
matevip 2026-05-01 09:51:07 +08:00
parent c46beaea9c
commit 7dd70ecd2f
2 changed files with 185 additions and 48 deletions

View File

@ -10,6 +10,7 @@ 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.repository.AuditEventMapper;
import vip.mate.audit.service.AuditEventService;
import vip.mate.common.result.R;
@ -49,8 +50,28 @@ import java.util.Map;
public class ActivityFeedController {
private final AuditEventService auditEventService;
private final AuditEventMapper auditEventMapper;
private final ToolApprovalMapper toolApprovalMapper;
/**
* RFC-090 §4.5 paginated activity feed.
*
* <p>Pagination strategy:
* <ul>
* <li><b>Single-source filter</b> (source=audit | approval)
* direct {@code BaseMapper.selectPage(...)} on the matching
* table. Both total and records are SQL-accurate.</li>
* <li><b>Combined feed</b> (source unset) fetch
* {@code page*size} rows from each side, merge by time-desc,
* slice to the requested window. {@code total} is the sum
* of {@code selectCount} across both tables exact for
* count, best-effort for time-merge ordering at very deep
* page numbers (the merge buffer is bounded but typical
* use stays within a few hundred rows).</li>
* </ul>
*
* <p>Caps: {@code size} clamped to [1, 200]; {@code page} 1.
*/
@Operation(summary = "Unified activity feed (audit + approval + tool calls)")
@GetMapping("/feed")
public R<Map<String, Object>> feed(
@ -58,44 +79,44 @@ public class ActivityFeedController {
@RequestParam(required = false) String source,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
if (size <= 0 || size > 200) size = 20;
if (size <= 0) size = 20;
if (size > 200) size = 200;
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 */ }
// Single-source path: direct SQL pagination
if (wantAudit && !wantApproval) {
return R.ok(pageAuditOnly(workspaceId, page, size));
}
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 */ }
if (wantApproval && !wantAudit) {
return R.ok(pageApprovalOnly(page, size));
}
// Combined path: per-source paginate + merge
// Fetch page*size from each side so the merged window contains
// the requested slice even in the worst case where one source
// dominates the timeline. This is wasteful at very deep pages
// but bounded a follow-up can push merging into SQL via a
// UNION ALL view if event volume gets into 10k+/day territory.
int bufferSize = Math.max(size * page, 50);
LambdaQueryWrapper<AuditEventEntity> auditQ = new LambdaQueryWrapper<AuditEventEntity>()
.orderByDesc(AuditEventEntity::getCreateTime);
if (workspaceId != null) auditQ.eq(AuditEventEntity::getWorkspaceId, workspaceId);
IPage<AuditEventEntity> auditPage = auditEventMapper.selectPage(new Page<>(1, bufferSize), auditQ);
LambdaQueryWrapper<ToolApprovalEntity> approvalQ = new LambdaQueryWrapper<ToolApprovalEntity>()
.orderByDesc(ToolApprovalEntity::getCreatedAt);
IPage<ToolApprovalEntity> approvalPage = toolApprovalMapper.selectPage(new Page<>(1, bufferSize), approvalQ);
List<ActivityRow> rows = new ArrayList<>();
for (AuditEventEntity ev : auditPage.getRecords()) rows.add(fromAuditEvent(ev));
for (ToolApprovalEntity ap : approvalPage.getRecords()) rows.add(fromApproval(ap));
rows.sort(Comparator.comparing(ActivityRow::time, Comparator.nullsLast(Comparator.reverseOrder())));
long total = rows.size();
long total = auditPage.getTotal() + approvalPage.getTotal();
int from = Math.min((page - 1) * size, rows.size());
int to = Math.min(from + size, rows.size());
List<ActivityRow> sliced = rows.subList(from, to);
@ -108,6 +129,38 @@ public class ActivityFeedController {
return R.ok(resp);
}
/** Pure SQL pagination on the audit_event table; total + records both
* come from the underlying {@link Page} object. */
private Map<String, Object> pageAuditOnly(Long workspaceId, int page, int size) {
LambdaQueryWrapper<AuditEventEntity> q = new LambdaQueryWrapper<AuditEventEntity>()
.orderByDesc(AuditEventEntity::getCreateTime);
if (workspaceId != null) q.eq(AuditEventEntity::getWorkspaceId, workspaceId);
IPage<AuditEventEntity> p = auditEventMapper.selectPage(new Page<>(page, size), q);
List<ActivityRow> records = new ArrayList<>(p.getRecords().size());
for (AuditEventEntity ev : p.getRecords()) records.add(fromAuditEvent(ev));
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("page", page);
resp.put("size", size);
resp.put("total", p.getTotal());
resp.put("records", records);
return resp;
}
/** Pure SQL pagination on the tool_approval table. */
private Map<String, Object> pageApprovalOnly(int page, int size) {
LambdaQueryWrapper<ToolApprovalEntity> q = new LambdaQueryWrapper<ToolApprovalEntity>()
.orderByDesc(ToolApprovalEntity::getCreatedAt);
IPage<ToolApprovalEntity> p = toolApprovalMapper.selectPage(new Page<>(page, size), q);
List<ActivityRow> records = new ArrayList<>(p.getRecords().size());
for (ToolApprovalEntity ap : p.getRecords()) records.add(fromApproval(ap));
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("page", page);
resp.put("size", size);
resp.put("total", p.getTotal());
resp.put("records", records);
return resp;
}
private ActivityRow fromAuditEvent(AuditEventEntity ev) {
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("detailJson", ev.getDetailJson());

View File

@ -98,11 +98,22 @@
</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>
<!-- Element Plus pagination supports size switcher / jumper /
total. When total fits the smallest page size we still render
so the user can adjust pageSize without it disappearing on
low-volume instances. -->
<div v-if="total > 0" class="pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[20, 50, 100]"
:hide-on-single-page="false"
background
layout="total, sizes, prev, pager, next, jumper"
@size-change="onPageSizeChange"
@current-change="loadEvents"
/>
</div>
</div>
</div>
@ -220,7 +231,8 @@ interface ActivityEvent {
const events = ref<ActivityEvent[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = 30
// Element Plus pagination owns this ref; @size-change writes through.
const pageSize = ref(30)
const total = ref(0)
const filters = reactive({ source: '', action: '', resourceType: '' })
const showMoreFilters = ref(false)
@ -354,7 +366,7 @@ async function loadEvents() {
const res: any = await activityApi.feed({
source: filters.source || undefined,
page: page.value,
size: pageSize,
size: pageSize.value,
})
events.value = res.data?.records || []
total.value = res.data?.total || 0
@ -365,6 +377,15 @@ async function loadEvents() {
}
}
function onPageSizeChange(newSize: number) {
pageSize.value = newSize
// Switching page size resets to page 1 Element Plus emits both
// size-change AND current-change, but order isn't guaranteed; we
// pin page=1 here so the request never fires with a stale offset.
page.value = 1
loadEvents()
}
function filterEventsLocally() { /* trigger recompute via reactive filter */ }
function openDetail(event: ActivityEvent) {
@ -456,21 +477,32 @@ onMounted(() => {
.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 chips — single row, white space normalized */
/* 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: var(--mc-bg-elevated); color: var(--mc-text-secondary);
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 orange brand accent, not raw text-primary which inverted
awkwardly in dark mode (white-on-white-ish). */
/* Active chip — brand-coloured, glass-tinted */
.filter-chip.active {
background: var(--mc-primary-bg);
color: var(--mc-primary);
@ -531,21 +563,28 @@ onMounted(() => {
padding: 14px 16px;
border: 1px solid var(--mc-border-light);
border-radius: 12px;
background: var(--mc-bg-elevated);
/* 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.15s, border-color 0.15s, box-shadow 0.15s;
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: var(--mc-bg-muted);
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. */
@ -617,10 +656,45 @@ onMounted(() => {
.pagination {
display: flex; align-items: center; justify-content: center;
gap: 14px; margin-top: 12px;
margin-top: 14px;
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 .pagination { background: rgba(34, 26, 22, 0.55); }
.page-info { font-size: 12px; color: var(--mc-text-tertiary); }
/* Element Plus pagination overrides match the brand aesthetic so
the off-the-shelf component blends with our glass cards. */
.pagination :deep(.el-pagination) {
--el-pagination-bg-color: transparent;
--el-pagination-button-bg-color: transparent;
--el-pagination-button-color: var(--mc-text-secondary);
--el-pagination-hover-color: var(--mc-primary);
font-family: inherit;
}
.pagination :deep(.el-pagination .btn-prev),
.pagination :deep(.el-pagination .btn-next),
.pagination :deep(.el-pager li) {
background: rgba(255, 255, 255, 0.6);
border-radius: 8px;
border: 1px solid var(--mc-border-light);
margin: 0 2px;
}
:root.dark .pagination :deep(.el-pagination .btn-prev),
:root.dark .pagination :deep(.el-pagination .btn-next),
:root.dark .pagination :deep(.el-pager li) {
background: rgba(42, 32, 26, 0.6);
}
.pagination :deep(.el-pager li.is-active) {
background: var(--mc-primary);
color: white;
border-color: var(--mc-primary);
}
/* Detail drawer — wider, no internal max-height frame */
.detail-shell {
padding: 24px 28px 32px;
@ -629,23 +703,33 @@ onMounted(() => {
.detail-hero {
/* Indented so the absolute-positioned dot sits inside, not behind
the drawer's left edge. The earlier `left: -16px` could clip on
drawers that don't have transparent borders. */
the drawer's left edge. */
position: relative;
padding: 4px 0 18px 18px;
border-bottom: 1px solid var(--mc-border-light);
padding: 18px 18px 22px;
margin: -8px -8px 0;
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: 0; top: 14px;
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;