diff --git a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java
index 343b5d62..839143ca 100644
--- a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java
+++ b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java
@@ -525,6 +525,90 @@ public class ApprovalWorkflowService implements ApplicationRunner {
}
}
+ // ==================== Global pending query (admin/notification surface) ====================
+
+ /**
+ * Default cap for {@link #listPendingFromDb(int)} when callers don't specify
+ * one, so a runaway pending table cannot drown the notification panel.
+ */
+ public static final int DEFAULT_PENDING_LIST_LIMIT = 200;
+
+ /**
+ * Hard ceiling regardless of caller-requested limit.
+ */
+ public static final int MAX_PENDING_LIST_LIMIT = 500;
+
+ /**
+ * Return every {@code PENDING} approval row, newest first, capped at {@code limit}.
+ * Reads from {@code mate_tool_approval} directly so restart / recovery edge
+ * cases cannot leave the in-memory map and the DB out of sync from a caller's
+ * perspective.
+ *
+ *
Payload shape matches {@link ApprovalService#getPendingByConversation},
+ * so the same frontend renderer can consume both surfaces.
+ */
+ public List> listPendingFromDb(int limit) {
+ int effectiveLimit = limit <= 0 ? DEFAULT_PENDING_LIST_LIMIT
+ : Math.min(limit, MAX_PENDING_LIST_LIMIT);
+ List rows;
+ try {
+ rows = approvalMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(ToolApprovalEntity::getStatus, "PENDING")
+ .orderByDesc(ToolApprovalEntity::getCreatedAt)
+ .last("LIMIT " + effectiveLimit)
+ );
+ } catch (Exception e) {
+ log.warn("[ApprovalWorkflow] listPendingFromDb failed: {}", e.getMessage());
+ return List.of();
+ }
+ return rows.stream().map(this::toPendingPayload).toList();
+ }
+
+ /**
+ * Count of pending approvals in {@code mate_tool_approval}. Used by the
+ * notification summary endpoint; cheap enough to call on every poll.
+ */
+ public long countPendingFromDb() {
+ try {
+ Long n = approvalMapper.selectCount(
+ new LambdaQueryWrapper()
+ .eq(ToolApprovalEntity::getStatus, "PENDING")
+ );
+ return n == null ? 0L : n;
+ } catch (Exception e) {
+ log.warn("[ApprovalWorkflow] countPendingFromDb failed: {}", e.getMessage());
+ return 0L;
+ }
+ }
+
+ private Map toPendingPayload(ToolApprovalEntity entity) {
+ java.util.LinkedHashMap entry = new java.util.LinkedHashMap<>();
+ entry.put("pendingId", entity.getPendingId());
+ entry.put("conversationId", entity.getConversationId());
+ entry.put("agentId", entity.getAgentId());
+ entry.put("toolName", entity.getToolName());
+ entry.put("toolArguments", entity.getToolArguments() != null ? entity.getToolArguments() : "");
+ entry.put("status", "pending");
+ entry.put("createdAt", entity.getCreatedAt() != null ? entity.getCreatedAt().toString() : null);
+ if (entity.getFindingsJson() != null) {
+ entry.put("findingsJson", entity.getFindingsJson());
+ }
+ if (entity.getMaxSeverity() != null) {
+ entry.put("maxSeverity", entity.getMaxSeverity());
+ }
+ if (entity.getSummary() != null) {
+ entry.put("summary", entity.getSummary());
+ }
+ if (entity.getChannelType() != null) {
+ entry.put("channelType", entity.getChannelType());
+ }
+ if (entity.getRequesterName() != null) {
+ entry.put("requesterName", entity.getRequesterName());
+ }
+ return entry;
+ }
+
// ---------- shared two-phase machinery ----------
private ResolveOutcome performResolve(String pendingId, String userId,
diff --git a/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java b/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java
new file mode 100644
index 00000000..1f1396ae
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java
@@ -0,0 +1,72 @@
+package vip.mate.notification;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import vip.mate.agent.runtime.AgentRuntimeAggregator;
+import vip.mate.approval.ApprovalWorkflowService;
+import vip.mate.common.result.R;
+import vip.mate.exception.MateClawException;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Aggregate counts that drive global UI attention signals (sidebar badges,
+ * future notification center).
+ *
+ * Designed so the frontend can poll a single endpoint instead of fan-out
+ * to every domain service. Fields with no settled "is it actually a problem"
+ * semantics (failed crons / down channels / down MCP servers) are returned
+ * as zero placeholders so the wire shape is stable and later phases can
+ * populate them without bumping the contract.
+ */
+@Slf4j
+@Tag(name = "Notifications")
+@RestController
+@RequestMapping("/api/v1/notifications")
+@RequiredArgsConstructor
+public class NotificationController {
+
+ private final ApprovalWorkflowService approvalWorkflowService;
+ private final AgentRuntimeAggregator agentRuntimeAggregator;
+
+ @Operation(summary = "Aggregated counts for the sidebar attention badges")
+ @GetMapping("/summary")
+ public R> summary(Authentication auth) {
+ boolean admin = isAdmin(auth);
+
+ // Cast to int — counts won't exceed Integer.MAX_VALUE in practice
+ // and the project's global Jackson config serializes Long as a string
+ // (for ID precision), which would break the numeric UI badge.
+ int pendingApprovals = (int) Math.min(Integer.MAX_VALUE, approvalWorkflowService.countPendingFromDb());
+ int stuckAgents = admin
+ ? agentRuntimeAggregator.snapshot().summary().stuck()
+ : 0;
+
+ Map payload = new LinkedHashMap<>();
+ payload.put("pendingApprovals", pendingApprovals);
+ payload.put("stuckAgents", stuckAgents);
+ // Reserved fields — wire shape stays stable so the frontend doesn't
+ // need a fan-out when these get real semantics later.
+ payload.put("failedCrons", 0);
+ payload.put("downChannels", 0);
+ payload.put("downMcps", 0);
+ return R.ok(payload);
+ }
+
+ private boolean isAdmin(Authentication auth) {
+ if (auth == null) {
+ throw new MateClawException(401, "authentication required");
+ }
+ return auth.getAuthorities().stream()
+ .map(GrantedAuthority::getAuthority)
+ .anyMatch("ROLE_ADMIN"::equals);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/controller/SecurityController.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/controller/SecurityController.java
index 7e279fa9..26df385f 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/guard/controller/SecurityController.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/controller/SecurityController.java
@@ -178,11 +178,13 @@ public class SecurityController {
@Operation(summary = "审批记录(管理视角)")
@GetMapping("/approvals")
public R listApprovals(
- @RequestParam(required = false) String conversationId) {
+ @RequestParam(required = false) String conversationId,
+ @RequestParam(required = false, defaultValue = "0") int limit) {
if (conversationId != null && !conversationId.isBlank()) {
return R.ok(approvalWorkflowService.getPendingByConversation(conversationId));
}
- // 返回空列表(后续可扩展为全量审批记录查询)
- return R.ok(java.util.List.of());
+ // Global view — reads from mate_tool_approval directly so the result
+ // survives in-memory map drift after a restart/recovery cycle.
+ return R.ok(approvalWorkflowService.listPendingFromDb(limit));
}
}
diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts
index a561e353..74469073 100644
--- a/mateclaw-ui/src/api/index.ts
+++ b/mateclaw-ui/src/api/index.ts
@@ -293,6 +293,19 @@ export const backstageApi = {
sweep: () => http.post('/admin/agent-runtime/sweep'),
}
+// ==================== Notification summary (sidebar attention badges) ====================
+export interface NotificationSummary {
+ pendingApprovals: number
+ stuckAgents: number
+ failedCrons: number
+ downChannels: number
+ downMcps: number
+}
+
+export const notificationApi = {
+ summary: () => http.get<{ data: NotificationSummary }>('/notifications/summary'),
+}
+
// ==================== ACP Endpoints (RFC-090 Phase 7) ====================
export const acpApi = {
list: () => http.get('/acp/endpoints'),
diff --git a/mateclaw-ui/src/components/common/NavBadge.vue b/mateclaw-ui/src/components/common/NavBadge.vue
new file mode 100644
index 00000000..34a7ff33
--- /dev/null
+++ b/mateclaw-ui/src/components/common/NavBadge.vue
@@ -0,0 +1,123 @@
+
+
+ {{ display }}
+
+
+
+
+
+
diff --git a/mateclaw-ui/src/composables/useNotificationCenter.ts b/mateclaw-ui/src/composables/useNotificationCenter.ts
new file mode 100644
index 00000000..2ce7e2e5
--- /dev/null
+++ b/mateclaw-ui/src/composables/useNotificationCenter.ts
@@ -0,0 +1,102 @@
+/**
+ * Shared notification-summary store + poller.
+ *
+ * Centralizes the counts that drive sidebar attention badges so every
+ * subscriber reads from the same cached snapshot — multiple mount/unmount
+ * cycles don't multiply HTTP traffic.
+ *
+ * Non-admin users never poll: the backend's per-user view today returns
+ * pendingApprovals only, and stuckAgents requires admin. The existing UI
+ * convention (MainLayout) already hides the global attention dot from
+ * non-admins, so we mirror it here.
+ */
+import { computed, onScopeDispose, ref } from 'vue'
+import { notificationApi, type NotificationSummary } from '@/api'
+
+const POLL_INTERVAL_MS = 15_000
+
+const summary = ref({
+ pendingApprovals: 0,
+ stuckAgents: 0,
+ failedCrons: 0,
+ downChannels: 0,
+ downMcps: 0,
+})
+
+let refCount = 0
+let timer: ReturnType | null = null
+let inFlight = false
+
+function isAdminRole(): boolean {
+ return (localStorage.getItem('role') || 'user') === 'admin'
+}
+
+/**
+ * The server's global Jackson config serializes Long as a string for
+ * ID precision — counts can come through as either number or stringified
+ * number depending on whether the backend casts to int. Coerce here so
+ * the badge always sees a real number regardless of which side fixed it.
+ */
+function toCount(v: unknown): number {
+ if (typeof v === 'number') return Number.isFinite(v) ? v : 0
+ if (typeof v === 'string') {
+ const n = Number(v)
+ return Number.isFinite(n) ? n : 0
+ }
+ return 0
+}
+
+async function refresh(): Promise {
+ if (!isAdminRole()) return
+ if (inFlight) return
+ inFlight = true
+ try {
+ const res: any = await notificationApi.summary()
+ const raw = (res?.data ?? res) as Partial | undefined
+ if (raw) {
+ summary.value = {
+ pendingApprovals: toCount(raw.pendingApprovals),
+ stuckAgents: toCount(raw.stuckAgents),
+ failedCrons: toCount(raw.failedCrons),
+ downChannels: toCount(raw.downChannels),
+ downMcps: toCount(raw.downMcps),
+ }
+ }
+ } catch {
+ // Silent: stale counts beat a flapping badge.
+ } finally {
+ inFlight = false
+ }
+}
+
+function ensurePolling(): void {
+ if (timer || !isAdminRole()) return
+ // Fire once immediately so the badge doesn't lag the interval.
+ void refresh()
+ timer = setInterval(refresh, POLL_INTERVAL_MS)
+}
+
+function maybeStopPolling(): void {
+ if (refCount > 0) return
+ if (timer) {
+ clearInterval(timer)
+ timer = null
+ }
+}
+
+export function useNotificationCenter() {
+ refCount++
+ ensurePolling()
+
+ onScopeDispose(() => {
+ refCount = Math.max(0, refCount - 1)
+ maybeStopPolling()
+ })
+
+ return {
+ summary: computed(() => summary.value),
+ pendingApprovals: computed(() => summary.value.pendingApprovals),
+ stuckAgents: computed(() => summary.value.stuckAgents),
+ refresh,
+ }
+}
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index 2bf8c53c..b20604e9 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -401,6 +401,9 @@ export default {
roleUser: 'User',
roleAdmin: 'Admin',
},
+ notifications: {
+ pendingApprovals: '{n} tool call(s) pending approval',
+ },
backstage: {
kicker: 'Backstage',
title: 'See what your employees are doing',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index 578ae475..7a3fabad 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -1714,6 +1714,9 @@ export default {
testFailed: '连接失败,请检查配置',
},
},
+ notifications: {
+ pendingApprovals: '{n} 个工具调用等待审批',
+ },
backstage: {
kicker: '后台',
title: '看看你的数字员工在做什么',
diff --git a/mateclaw-ui/src/views/layout/MainLayout.vue b/mateclaw-ui/src/views/layout/MainLayout.vue
index 9f8c16d9..8a108f16 100644
--- a/mateclaw-ui/src/views/layout/MainLayout.vue
+++ b/mateclaw-ui/src/views/layout/MainLayout.vue
@@ -46,17 +46,26 @@
:key="item.path"
:to="item.path"
class="nav-item"
- :class="{ active: isNavItemActive(item), 'has-attention': item.path === '/backstage' && backstageAlertActive }"
+ :class="{ active: isNavItemActive(item) }"
:title="effectiveCollapsed ? (item.tooltip || item.label) : (item.tooltip || '')"
@click="onNavClick"
>
{{ item.label }}
-
+ />
+
@@ -173,10 +182,12 @@ import { useI18n } from 'vue-i18n'
import { useThemeStore } from '@/stores/useThemeStore'
import { version as appVersion } from '../../../package.json'
import type { ThemeMode } from '@/stores/useThemeStore'
-import { http, settingsApi, setupApi, backstageApi } from '@/api/index'
+import { http, settingsApi, setupApi } from '@/api/index'
import OnboardingWizard from '@/views/Onboarding/OnboardingWizard.vue'
import DoctorDrawer from '@/views/Doctor/DoctorDrawer.vue'
import WorkspaceSwitcher from '@/components/workspace/WorkspaceSwitcher.vue'
+import NavBadge from '@/components/common/NavBadge.vue'
+import { useNotificationCenter } from '@/composables/useNotificationCenter'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { applyLocale, currentLocale, type AppLocale } from '@/i18n'
import { SwitchButton, Lock } from '@element-plus/icons-vue'
@@ -210,24 +221,12 @@ async function fetchHealthStatus() {
}
}
-// Live attention signal for the Backstage sidebar entry. Admins only —
-// non-admin users never poll the runtime endpoint and never see the dot.
-const backstageStuckCount = ref(0)
-let backstagePollTimer: ReturnType | null = null
-
+// Sidebar attention signals — admin-only. Both `/backstage` (stuck agents)
+// and `/security` (pending approvals) read from a shared 15s poller so
+// multiple consumers don't multiply HTTP traffic.
const isAdminRole = computed(() => (localStorage.getItem('role') || 'user') === 'admin')
-const backstageAlertActive = computed(() => isAdminRole.value && backstageStuckCount.value > 0)
-
-async function refreshBackstageBadge() {
- if (!isAdminRole.value) return
- try {
- const res: any = await backstageApi.snapshot()
- const data = res?.data ?? res
- backstageStuckCount.value = data?.summary?.stuck ?? 0
- } catch {
- // Silent: stale value is preferable to a flapping indicator.
- }
-}
+const { stuckAgents, pendingApprovals } = useNotificationCenter()
+const backstageAlertActive = computed(() => isAdminRole.value && stuckAgents.value > 0)
// 移动端状态
const isMobile = ref(false)
@@ -274,18 +273,13 @@ onMounted(async () => {
// Fetch initial health status for sidebar indicator
fetchHealthStatus()
-
- // Backstage attention dot — poll every 15s for admins.
- if (isAdminRole.value) {
- refreshBackstageBadge()
- backstagePollTimer = setInterval(refreshBackstageBadge, 15_000)
- }
+ // Sidebar attention counts (backstage / security) are driven by
+ // useNotificationCenter — it polls when admins are mounted.
})
onBeforeUnmount(() => {
mobileQuery?.removeEventListener('change', handleMobileChange)
mediumQuery?.removeEventListener('change', handleMediumChange)
- if (backstagePollTimer) clearInterval(backstagePollTimer)
})
function onNavClick() {
@@ -673,44 +667,6 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
.nav-icon { display: flex; align-items: center; flex-shrink: 0; }
.nav-label { overflow: hidden; text-overflow: ellipsis; }
-/* Backstage attention dot — appears only when stuck > 0 for an admin. */
-.nav-attention-dot {
- position: absolute;
- right: 14px;
- top: 50%;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: hsl(20, 80%, 55%);
- transform: translateY(-50%);
- box-shadow: 0 0 0 0 hsla(20, 80%, 55%, 0.6);
- animation: nav-attention-pulse 2.4s ease-in-out infinite;
-}
-
-.nav-item.has-attention {
- color: hsl(20, 75%, 50%);
-}
-
-@keyframes nav-attention-pulse {
- 0%, 100% { box-shadow: 0 0 0 0 hsla(20, 80%, 55%, 0.55); transform: translateY(-50%) scale(1); }
- 50% { box-shadow: 0 0 0 6px hsla(20, 80%, 55%, 0); transform: translateY(-50%) scale(1.15); }
-}
-
-.sidebar.collapsed .nav-attention-dot {
- right: 8px;
- top: 8px;
- transform: none;
-}
-
-.sidebar.collapsed .nav-attention-dot {
- animation-name: nav-attention-pulse-collapsed;
-}
-
-@keyframes nav-attention-pulse-collapsed {
- 0%, 100% { box-shadow: 0 0 0 0 hsla(20, 80%, 55%, 0.55); transform: scale(1); }
- 50% { box-shadow: 0 0 0 5px hsla(20, 80%, 55%, 0); transform: scale(1.2); }
-}
-
/* 底部 */
.sidebar-footer {
border-top: 1px solid var(--mc-border-light);