mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui): surface pending approvals + stuck agents as sidebar badges
This commit is contained in:
parent
adf5d93975
commit
eb6badeb61
@ -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.
|
||||
*
|
||||
* <p>Payload shape matches {@link ApprovalService#getPendingByConversation},
|
||||
* so the same frontend renderer can consume both surfaces.
|
||||
*/
|
||||
public List<Map<String, Object>> listPendingFromDb(int limit) {
|
||||
int effectiveLimit = limit <= 0 ? DEFAULT_PENDING_LIST_LIMIT
|
||||
: Math.min(limit, MAX_PENDING_LIST_LIMIT);
|
||||
List<ToolApprovalEntity> rows;
|
||||
try {
|
||||
rows = approvalMapper.selectList(
|
||||
new LambdaQueryWrapper<ToolApprovalEntity>()
|
||||
.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<ToolApprovalEntity>()
|
||||
.eq(ToolApprovalEntity::getStatus, "PENDING")
|
||||
);
|
||||
return n == null ? 0L : n;
|
||||
} catch (Exception e) {
|
||||
log.warn("[ApprovalWorkflow] countPendingFromDb failed: {}", e.getMessage());
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> toPendingPayload(ToolApprovalEntity entity) {
|
||||
java.util.LinkedHashMap<String, Object> 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,
|
||||
|
||||
@ -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).
|
||||
*
|
||||
* <p>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<Map<String, Object>> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
@ -178,11 +178,13 @@ public class SecurityController {
|
||||
@Operation(summary = "审批记录(管理视角)")
|
||||
@GetMapping("/approvals")
|
||||
public R<Object> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
|
||||
123
mateclaw-ui/src/components/common/NavBadge.vue
Normal file
123
mateclaw-ui/src/components/common/NavBadge.vue
Normal file
@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<span
|
||||
v-if="visible"
|
||||
class="nav-badge"
|
||||
:class="[`nav-badge--${tone}`, badgeMode, { 'is-collapsed': collapsed }]"
|
||||
:title="title"
|
||||
>
|
||||
<template v-if="mode === 'count'">{{ display }}</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
/**
|
||||
* Sidebar attention badge. Two modes:
|
||||
* - dot: silent presence indicator (replaces the legacy backstage pulse).
|
||||
* - count: small pill with an actionable number (e.g. pending approvals).
|
||||
*
|
||||
* When the host nav item collapses, the badge floats to the top-right corner
|
||||
* so it stays visible without competing with the icon.
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Count to render; <= 0 hides the badge in count mode. */
|
||||
count?: number
|
||||
/** Force dot mode regardless of count (used for "something needs attention" signals without a number). */
|
||||
dot?: boolean
|
||||
/** Visual severity. urgent = red, warning = orange (matches the legacy backstage tint). */
|
||||
tone?: 'urgent' | 'warning'
|
||||
/** Whether the surrounding sidebar is collapsed; controls corner placement. */
|
||||
collapsed?: boolean
|
||||
/** Optional tooltip on hover. */
|
||||
title?: string
|
||||
}>(),
|
||||
{
|
||||
count: 0,
|
||||
dot: false,
|
||||
tone: 'urgent',
|
||||
collapsed: false,
|
||||
title: '',
|
||||
}
|
||||
)
|
||||
|
||||
const mode = computed<'dot' | 'count'>(() => (props.dot ? 'dot' : 'count'))
|
||||
const visible = computed(() => (mode.value === 'dot' ? true : props.count > 0))
|
||||
const display = computed(() => (props.count > 99 ? '99+' : String(props.count)))
|
||||
const badgeMode = computed(() => `nav-badge--${mode.value}`)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-badge {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Tone — urgent (red) */
|
||||
.nav-badge--urgent {
|
||||
--badge-bg: hsl(0, 75%, 55%);
|
||||
--badge-pulse: hsla(0, 75%, 55%, 0.55);
|
||||
}
|
||||
|
||||
/* Tone — warning (orange) — matches the legacy backstage attention color */
|
||||
.nav-badge--warning {
|
||||
--badge-bg: hsl(20, 80%, 55%);
|
||||
--badge-pulse: hsla(20, 80%, 55%, 0.55);
|
||||
}
|
||||
|
||||
/* Dot mode: pulsing 8px circle */
|
||||
.nav-badge--dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--badge-bg);
|
||||
animation: nav-badge-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Count mode: rounded pill, no pulse (the changing number is the signal) */
|
||||
.nav-badge--count {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 9px;
|
||||
color: #fff;
|
||||
background: var(--badge-bg);
|
||||
}
|
||||
|
||||
@keyframes nav-badge-pulse {
|
||||
0%, 100% { transform: translateY(-50%) scale(1); box-shadow: 0 0 0 0 var(--badge-pulse); }
|
||||
50% { transform: translateY(-50%) scale(1.15); box-shadow: 0 0 0 6px transparent; }
|
||||
}
|
||||
|
||||
/* Collapsed sidebar — float to the top-right corner. */
|
||||
.nav-badge.is-collapsed {
|
||||
right: 6px;
|
||||
top: 6px;
|
||||
transform: none;
|
||||
}
|
||||
.nav-badge--dot.is-collapsed {
|
||||
animation-name: nav-badge-pulse-corner;
|
||||
}
|
||||
.nav-badge--count.is-collapsed {
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@keyframes nav-badge-pulse-corner {
|
||||
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 var(--badge-pulse); }
|
||||
50% { transform: scale(1.2); box-shadow: 0 0 0 5px transparent; }
|
||||
}
|
||||
</style>
|
||||
102
mateclaw-ui/src/composables/useNotificationCenter.ts
Normal file
102
mateclaw-ui/src/composables/useNotificationCenter.ts
Normal file
@ -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<NotificationSummary>({
|
||||
pendingApprovals: 0,
|
||||
stuckAgents: 0,
|
||||
failedCrons: 0,
|
||||
downChannels: 0,
|
||||
downMcps: 0,
|
||||
})
|
||||
|
||||
let refCount = 0
|
||||
let timer: ReturnType<typeof setInterval> | 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<void> {
|
||||
if (!isAdminRole()) return
|
||||
if (inFlight) return
|
||||
inFlight = true
|
||||
try {
|
||||
const res: any = await notificationApi.summary()
|
||||
const raw = (res?.data ?? res) as Partial<NotificationSummary> | 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,
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -1714,6 +1714,9 @@ export default {
|
||||
testFailed: '连接失败,请检查配置',
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
pendingApprovals: '{n} 个工具调用等待审批',
|
||||
},
|
||||
backstage: {
|
||||
kicker: '后台',
|
||||
title: '看看你的数字员工在做什么',
|
||||
|
||||
@ -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"
|
||||
>
|
||||
<span class="nav-icon" v-html="item.icon"></span>
|
||||
<span v-if="!effectiveCollapsed" class="nav-label">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.path === '/backstage' && backstageAlertActive"
|
||||
class="nav-attention-dot"
|
||||
<NavBadge
|
||||
v-if="item.path === '/backstage'"
|
||||
:dot="backstageAlertActive"
|
||||
tone="warning"
|
||||
:collapsed="effectiveCollapsed"
|
||||
:title="t('backstage.attention')"
|
||||
></span>
|
||||
/>
|
||||
<NavBadge
|
||||
v-else-if="item.path === '/security' && isAdminRole"
|
||||
:count="pendingApprovals"
|
||||
tone="urgent"
|
||||
:collapsed="effectiveCollapsed"
|
||||
:title="t('notifications.pendingApprovals', { n: pendingApprovals })"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
@ -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<typeof setInterval> | 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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user