diff --git a/ordo-editor/apps/studio/src/components/layout/AppLayout.vue b/ordo-editor/apps/studio/src/components/layout/AppLayout.vue index f6dc105b..5157e356 100644 --- a/ordo-editor/apps/studio/src/components/layout/AppLayout.vue +++ b/ordo-editor/apps/studio/src/components/layout/AppLayout.vue @@ -127,6 +127,83 @@ function notifIcon(type: string) { return 'info-circle'; } +// ── Bell dropdown: merge server-backed inbox + transient toasts ─────────────── +// The badge counts persistent notifications (release reviews/approvals, polled +// as a count) plus transient session toasts. The dropdown must therefore render +// both — otherwise a release-review notification lights the badge but the +// dropdown opens to "no notifications". + +function persistentNotifTitle(type: string, payload: Record) { + const title = (payload.title as string) ?? ''; + if (type === 'release_review_requested') + return t('notifications.types.releaseReviewRequested', { title }); + if (type === 'release_approved') return t('notifications.types.releaseApproved', { title }); + if (type === 'release_rejected') return t('notifications.types.releaseRejected', { title }); + return type; +} + +function persistentNotifIcon(type: string) { + if (type === 'release_approved') return 'check-circle'; + if (type === 'release_rejected') return 'close-circle'; + if (type === 'release_review_requested') return 'notification'; + return 'info-circle'; +} + +interface BellItem { + id: string; + icon: string; + iconType: string; + title: string; + message?: string; + time: Date; + read: boolean; + onClick?: () => void; +} + +const bellItems = computed(() => { + const persistent: BellItem[] = persistentNotifStore.notifications.map((n) => ({ + id: `p:${n.id}`, + icon: persistentNotifIcon(n.type), + iconType: + n.type === 'release_approved' ? 'success' : n.type === 'release_rejected' ? 'error' : 'info', + title: persistentNotifTitle(n.type, n.payload), + time: new Date(n.created_at), + read: !!n.read_at, + onClick: () => { + const pid = n.payload.project_id as string | undefined; + if (!n.ref_id || !pid || !currentOrgId.value) return; + notifOpen.value = false; + if (!n.read_at) void persistentNotifStore.markRead(currentOrgId.value, n.id); + navigate(`/orgs/${currentOrgId.value}/projects/${pid}/releases`); + }, + })); + const transient: BellItem[] = notifStore.notifications.map((n) => ({ + id: `t:${n.id}`, + icon: notifIcon(n.type), + iconType: n.type, + title: n.title, + message: n.message, + time: n.timestamp, + read: n.read, + })); + return [...persistent, ...transient].sort((a, b) => b.time.getTime() - a.time.getTime()); +}); + +const bellUnread = computed(() => persistentNotifStore.unreadCount + notifStore.unreadCount); + +function toggleNotif() { + notifOpen.value = !notifOpen.value; + // Populate the server-backed list on open (polling only keeps the count). + if (notifOpen.value && currentOrgId.value) { + void persistentNotifStore.fetchNotifications(currentOrgId.value, false).catch(() => {}); + } +} + +async function handleBellMarkAllRead() { + notifStore.markAllRead(); + if (currentOrgId.value) await persistentNotifStore.markAllRead(currentOrgId.value); +} + // ── Page info ──────────────────────────────────────────────────────────────── const pageInfo = computed(() => { @@ -617,20 +694,13 @@ async function handleCreateOrg() { class="topbar-pill topbar-pill--icon" :class="{ 'is-open': notifOpen, - 'has-unread': persistentNotifStore.unreadCount > 0 || notifStore.unreadCount > 0, + 'has-unread': bellUnread > 0, }" - @click="notifOpen = !notifOpen" + @click="toggleNotif()" > - - {{ - persistentNotifStore.unreadCount + notifStore.unreadCount > 99 - ? '99+' - : persistentNotifStore.unreadCount + notifStore.unreadCount - }} + + {{ bellUnread > 99 ? '99+' : bellUnread }} @@ -638,33 +708,34 @@ async function handleCreateOrg() {
{{ t('shell.notifications') }}
-
+
{{ t('shell.noNotifications') }}
- - + +
{{ n.title }}
{{ n.message }}
-
{{ formatNotifTime(n.timestamp) }}
+
{{ formatNotifTime(n.time) }}
@@ -1359,6 +1430,10 @@ async function handleCreateOrg() { background: var(--ordo-bg-selected); } +.notif-item--clickable { + cursor: pointer; +} + .notif-icon { width: 26px; height: 26px; diff --git a/ordo-editor/apps/studio/src/i18n/locales/en.ts b/ordo-editor/apps/studio/src/i18n/locales/en.ts index d20e4705..f8e232e3 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/en.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/en.ts @@ -338,6 +338,7 @@ export default { emptyTitle: 'No execution data yet', emptyHint: 'Once your connected engine runs traffic it will appear here. This needs the engine to reach the platform over NATS.', + errorTitle: "Couldn't load analytics", }, integrate: { title: 'Integrate', @@ -1215,6 +1216,8 @@ export default { title: 'Notifications', markAllRead: 'Mark all read', empty: 'No notifications', + error: 'Failed to load notifications', + retry: 'Retry', unreadOnly: 'Unread only', viewInbox: 'View inbox', types: { diff --git a/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts b/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts index 8e7aabd5..18318bcd 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/zh-CN.ts @@ -330,6 +330,7 @@ export default { ruleset: '规则集', emptyTitle: '暂无执行数据', emptyHint: '已连接的引擎开始有流量后,数据会显示在这里。需要引擎能通过 NATS 连到平台。', + errorTitle: '分析数据加载失败', }, integrate: { title: '接入', @@ -1182,6 +1183,8 @@ export default { title: '通知', markAllRead: '全部标为已读', empty: '暂无通知', + error: '通知加载失败', + retry: '重试', unreadOnly: '仅显示未读', viewInbox: '查看收件箱', types: { diff --git a/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts b/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts index 2c86ca68..bdcf1530 100644 --- a/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts +++ b/ordo-editor/apps/studio/src/i18n/locales/zh-TW.ts @@ -330,6 +330,7 @@ export default { ruleset: '規則集', emptyTitle: '暫無執行資料', emptyHint: '已連接的引擎開始有流量後,資料會顯示在這裡。需要引擎能透過 NATS 連到平台。', + errorTitle: '分析資料載入失敗', }, integrate: { title: '接入', @@ -1182,6 +1183,8 @@ export default { title: '通知', markAllRead: '全部標為已讀', empty: '暫無通知', + error: '通知載入失敗', + retry: '重試', unreadOnly: '僅顯示未讀', viewInbox: '查看收件箱', types: { diff --git a/ordo-editor/apps/studio/src/stores/persistentNotifications.ts b/ordo-editor/apps/studio/src/stores/persistentNotifications.ts index f092efca..93e00296 100644 --- a/ordo-editor/apps/studio/src/stores/persistentNotifications.ts +++ b/ordo-editor/apps/studio/src/stores/persistentNotifications.ts @@ -21,19 +21,18 @@ export const usePersistentNotificationStore = defineStore('persistentNotificatio } } + // Surfaces errors to the caller (throws) so a failed load can render an error + // state instead of silently looking like an empty inbox. Background callers + // that don't care (e.g. the bell peek) should catch and ignore. async function fetchNotifications(orgId: string, unreadOnly = false) { if (!auth.token || !orgId) return; - try { - notifications.value = await notificationApi.list(auth.token, orgId, { - unread_only: unreadOnly, - limit: 50, - }); - unreadCount.value = unreadOnly - ? notifications.value.length - : notifications.value.filter((n) => !n.read_at).length; - } catch { - // ignore - } + notifications.value = await notificationApi.list(auth.token, orgId, { + unread_only: unreadOnly, + limit: 50, + }); + unreadCount.value = unreadOnly + ? notifications.value.length + : notifications.value.filter((n) => !n.read_at).length; } async function markRead(orgId: string, notifId: string) { diff --git a/ordo-editor/apps/studio/src/views/auth/LoginView.vue b/ordo-editor/apps/studio/src/views/auth/LoginView.vue index e40ecdbe..88352757 100644 --- a/ordo-editor/apps/studio/src/views/auth/LoginView.vue +++ b/ordo-editor/apps/studio/src/views/auth/LoginView.vue @@ -113,12 +113,7 @@ async function handleLogin() {
-
- - {{ - t('auth.forgotPassword') - }} -
+ import { computed, onMounted, ref } from 'vue'; -import { useRouter } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; import { useI18n } from 'vue-i18n'; import { usePersistentNotificationStore } from '@/stores/persistentNotifications'; import { useOrgStore } from '@/stores/org'; const { t } = useI18n(); +const route = useRoute(); const router = useRouter(); const orgStore = useOrgStore(); const store = usePersistentNotificationStore(); -const orgId = computed(() => orgStore.currentOrg?.id ?? ''); +// Prefer the URL's org so a deep link / hard reload loads the right inbox even +// before the shell has settled on a current org. +const orgId = computed( + () => (route.params.orgId as string | undefined) || orgStore.currentOrg?.id || '' +); const unreadOnly = ref(false); const loading = ref(false); +const error = ref(null); -onMounted(async () => { +async function load() { if (!orgId.value) return; loading.value = true; + error.value = null; try { await store.fetchNotifications(orgId.value, false); + } catch (e) { + // Surface the failure instead of rendering it as an empty inbox. + error.value = e instanceof Error ? e.message : t('notifications.error'); } finally { loading.value = false; } -}); +} + +onMounted(load); const filtered = computed(() => unreadOnly.value ? store.notifications.filter((n) => !n.read_at) : store.notifications @@ -97,6 +109,14 @@ function navigateToRelease(n: { ref_id?: string; payload: Record
+
+ +

{{ error }}

+ + {{ t('notifications.retry') }} + +
+

{{ t('notifications.empty') }}

diff --git a/ordo-editor/apps/studio/src/views/project/AnalyticsView.vue b/ordo-editor/apps/studio/src/views/project/AnalyticsView.vue index 635b517e..fc40d5ad 100644 --- a/ordo-editor/apps/studio/src/views/project/AnalyticsView.vue +++ b/ordo-editor/apps/studio/src/views/project/AnalyticsView.vue @@ -207,8 +207,17 @@ onBeforeUnmount(() => {
+ + +

{{ t('analytics.errorTitle') }}

+

{{ analytics.error }}

+ + {{ t('analytics.refresh') }} + +
+ - +

{{ t('analytics.emptyTitle') }}

{{ t('analytics.emptyHint') }}

@@ -323,6 +332,9 @@ onBeforeUnmount(() => { color: var(--td-text-color-secondary); margin: 0; } +.empty-action { + margin-top: 16px; +} @media (max-width: 900px) { .stat-row { grid-template-columns: repeat(2, 1fr);