This is an automated email from the ASF dual-hosted git repository. zqr10159 pushed a commit to branch 2.0.0 in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
commit 95c78995336d201f9600f58979a58b5fa7f4a1f4 Author: Logic <[email protected]> AuthorDate: Fri May 29 02:02:27 2026 +0800 feat(web-next): restore bulletin workbench compatibility Validation: vitest run app/bulletin/page.test.tsx components/pages/bulletin-center-surface.test.tsx components/pages/bulletin-manage-dialog.test.tsx components/pages/bulletin-metrics-table.test.tsx components/pages/bulletin-monitor-detail.chrome.test.ts lib/bulletin-center/controller.test.ts lib/bulletin-center/query-state.test.ts lib/bulletin-center/view-model.test.ts --pool=forks --maxWorkers=1 --minWorkers=1; ESLint same bulletin route/component/lib set; git diff --cached --check; [...] --- web-next/app/bulletin/bulletin-page.tsx | 37 ++++++ web-next/app/bulletin/page.test.tsx | 28 ++++- web-next/app/bulletin/page.tsx | 24 +--- .../pages/bulletin-center-surface.test.tsx | 35 ++++-- .../components/pages/bulletin-center-surface.tsx | 13 ++- .../components/pages/bulletin-manage-dialog.tsx | 2 +- .../pages/bulletin-metrics-table.test.tsx | 124 +++++++++++++++++++++ .../components/pages/bulletin-metrics-table.tsx | 90 ++++++++------- .../pages/bulletin-monitor-detail.chrome.test.ts | 25 ++--- web-next/lib/bulletin-center/view-model.test.ts | 85 ++++++++++++-- web-next/lib/bulletin-center/view-model.ts | 37 ++++-- 11 files changed, 382 insertions(+), 118 deletions(-) diff --git a/web-next/app/bulletin/bulletin-page.tsx b/web-next/app/bulletin/bulletin-page.tsx new file mode 100644 index 0000000000..8883fb27ed --- /dev/null +++ b/web-next/app/bulletin/bulletin-page.tsx @@ -0,0 +1,37 @@ +'use client'; + +import React, { useCallback, useMemo, useState } from 'react'; +import { BulletinCenterSurface, type BulletinCenterData } from '../../components/pages/bulletin-center-surface'; +import { useI18n } from '../../components/providers/i18n-provider'; +import { ClientWorkbench } from '../../components/workbench/client-workbench'; +import { apiMessageGet } from '../../lib/api-client'; +import { loadBulletinData } from '../../lib/bulletin-center/controller'; +import { buildBulletinListUrl } from '../../lib/bulletin-center/query-state'; + +const BULLETIN_CENTER_SETTLED_CACHE_TTL_MS = 10_000; + +export default function BulletinPage() { + const { t } = useI18n(); + const [refreshTick, setRefreshTick] = useState(0); + const bulletinListSearch = ' '.repeat(refreshTick); + const bulletinListUrl = useMemo(() => buildBulletinListUrl(bulletinListSearch), [bulletinListSearch]); + const bulletinCenterCacheKey = useMemo( + () => ['bulletin-center', bulletinListUrl, refreshTick].join(':'), + [bulletinListUrl, refreshTick] + ); + + const load = useCallback(async (): Promise<BulletinCenterData> => { + return loadBulletinData(apiMessageGet, bulletinListSearch); + }, [bulletinListSearch]); + + return ( + <ClientWorkbench + load={load} + loadingCopy={t('bulletin.loading')} + cacheKey={bulletinCenterCacheKey} + cacheSettledTtlMs={BULLETIN_CENTER_SETTLED_CACHE_TTL_MS} + > + {data => <BulletinCenterSurface data={data} refreshTick={refreshTick} onReload={() => setRefreshTick(value => value + 1)} />} + </ClientWorkbench> + ); +} diff --git a/web-next/app/bulletin/page.test.tsx b/web-next/app/bulletin/page.test.tsx index e6e7fa11f1..1b342fb474 100644 --- a/web-next/app/bulletin/page.test.tsx +++ b/web-next/app/bulletin/page.test.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; import { createTranslatorMock } from '../../test/i18n-test-helper'; @@ -20,15 +22,24 @@ const loadBulletinData = vi.fn(async () => ({ vi.mock('../../components/providers/i18n-provider', () => ({ useI18n: () => ({ - t: createTranslatorMock() + t: createTranslatorMock({ locale: 'en-US' }), + locale: 'en-US' }) })); vi.mock('../../components/workbench/client-workbench', () => ({ - ClientWorkbench: ({ children, load }: { children: (data: any) => React.ReactNode; load: () => Promise<unknown> }) => { + ClientWorkbench: ({ + children, + load, + loadingCopy + }: { + children: (data: any) => React.ReactNode; + load: () => Promise<unknown>; + loadingCopy?: string; + }) => { loadState.lastLoad = load; return ( - <div data-client-workbench="true"> + <div data-client-workbench="true" data-loading-copy={loadingCopy}> {children({ list: { totalElements: 2, @@ -77,7 +88,18 @@ describe('bulletin page', () => { expect(html).toContain('data-client-workbench="true"'); expect(html).toContain('data-bulletin-center-surface="true"'); + expect(html).toContain('data-loading-copy="Loading bulletin center"'); expect(html).toContain('Ops board'); expect(loadBulletinData).toHaveBeenCalledWith(apiMessageGet, ''); }, 15000); + + it('keeps bulletin center remounts on a short settled cache window while reload invalidates it', () => { + const source = readFileSync(resolve(process.cwd(), 'app/bulletin/bulletin-page.tsx'), 'utf8'); + + expect(source).toContain('BULLETIN_CENTER_SETTLED_CACHE_TTL_MS = 10_000'); + expect(source).toContain("['bulletin-center', bulletinListUrl, refreshTick].join(':')"); + expect(source).toContain('[bulletinListUrl, refreshTick]'); + expect(source).toContain('onReload={() => setRefreshTick(value => value + 1)}'); + expect(source).toContain('cacheSettledTtlMs={BULLETIN_CENTER_SETTLED_CACHE_TTL_MS}'); + }); }); diff --git a/web-next/app/bulletin/page.tsx b/web-next/app/bulletin/page.tsx index e68889cb1f..4f2f021fb0 100644 --- a/web-next/app/bulletin/page.tsx +++ b/web-next/app/bulletin/page.tsx @@ -1,23 +1,7 @@ -'use client'; +import React from 'react'; -import React, { useCallback, useState } from 'react'; -import { BulletinCenterSurface, type BulletinCenterData } from '../../components/pages/bulletin-center-surface'; -import { useI18n } from '../../components/providers/i18n-provider'; -import { ClientWorkbench } from '../../components/workbench/client-workbench'; -import { apiMessageGet } from '../../lib/api-client'; -import { loadBulletinData } from '../../lib/bulletin-center/controller'; +import BulletinPage from './bulletin-page'; -export default function BulletinPage() { - const { t } = useI18n(); - const [refreshTick, setRefreshTick] = useState(0); - - const load = useCallback(async (): Promise<BulletinCenterData> => { - return loadBulletinData(apiMessageGet, ' '.repeat(refreshTick)); - }, [refreshTick]); - - return ( - <ClientWorkbench load={load} loadingCopy={t('bulletin.loading')}> - {data => <BulletinCenterSurface data={data} refreshTick={refreshTick} onReload={() => setRefreshTick(value => value + 1)} />} - </ClientWorkbench> - ); +export default function BulletinRoutePage() { + return <BulletinPage />; } diff --git a/web-next/components/pages/bulletin-center-surface.test.tsx b/web-next/components/pages/bulletin-center-surface.test.tsx index be4a4f96e6..faf20bc389 100644 --- a/web-next/components/pages/bulletin-center-surface.test.tsx +++ b/web-next/components/pages/bulletin-center-surface.test.tsx @@ -4,10 +4,11 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; import { createTranslatorMock } from '../../test/i18n-test-helper'; +import { BulletinCenterSurface } from './bulletin-center-surface'; vi.mock('../providers/i18n-provider', () => ({ useI18n: () => ({ - t: createTranslatorMock() + t: createTranslatorMock({ locale: 'zh-CN' }) }) })); @@ -21,8 +22,8 @@ vi.mock('../workbench/action-toolbar', () => ({ })); vi.mock('../workbench/workspace-tab-strip', () => ({ - WorkspaceTabStrip: ({ tabs }: any) => ( - <div data-bulletin-tabs="true"> + WorkspaceTabStrip: ({ tabs, ariaLabel }: any) => ( + <div data-bulletin-tabs="true" aria-label={ariaLabel}> {tabs.map((tab: any) => ( <button key={tab.key} type="button"> {tab.label} @@ -59,8 +60,7 @@ vi.mock('../ui/checkbox', () => ({ })); describe('bulletin center surface', () => { - it('renders the shared HertzBeat toolbar, tabs, and metrics desk shell', async () => { - const { BulletinCenterSurface } = await import('./bulletin-center-surface'); + it('renders the shared HertzBeat toolbar, tabs, and metrics desk shell', () => { const html = renderToStaticMarkup( <BulletinCenterSurface data={{ @@ -80,11 +80,12 @@ describe('bulletin center surface', () => { expect(html).toContain('data-bulletin-center-surface="true"'); expect(html).toContain('data-action-toolbar="true"'); expect(html).toContain('data-bulletin-tabs="true"'); + expect(html).toContain('aria-label="公告导航"'); expect(html).toContain('data-bulletin-metrics-table="true"'); expect(html).toContain('Ops board'); expect(html).toContain('DB board'); - expect(html).toContain('Refresh'); - expect(html).toContain('New'); + expect(html).toContain('刷新'); + expect(html).toContain('新增'); }); it('routes current bulletin deletion through a cold modal instead of native confirm', () => { @@ -94,9 +95,23 @@ describe('bulletin center surface', () => { expect(source).not.toContain('confirm('); expect(source).toContain('data-bulletin-delete-confirm-trigger="cold-modal"'); expect(source).toContain('data-bulletin-delete-confirm="cold-modal"'); - expect(source).toContain('确认删除公告'); - expect(source).toContain('确认删除'); - expect(source).toContain('取消'); + expect(source).toContain("ariaLabel={t('bulletin.navigation.aria')}"); + expect(source).not.toContain('ariaLabel="Bulletin navigation"'); + expect(source).toContain("t('bulletin.delete.title')"); + expect(source).toContain("t('bulletin.delete.copy')"); + expect(source).toContain("t('bulletin.delete.confirm')"); + expect(source).toContain("t('common.cancel')"); + expect(source).not.toContain('确认删除公告'); + expect(source).not.toContain('删除后该公告看板会从当前工作台移除'); + }); + + it('trims blank current bulletin names to the localized empty fallback', () => { + const source = readFileSync(resolve(process.cwd(), 'components/pages/bulletin-center-surface.tsx'), 'utf8'); + + expect(source).toContain("const currentBulletinName = (activeBulletin?.name || '').trim() || t('common.none');"); + expect(source).toContain('{currentBulletinName}'); + expect(source).not.toContain("const currentBulletinName = activeBulletin?.name || t('common.none');"); + expect(source).not.toContain("activeBulletin?.name || '-'"); }); it('uses the shared cold checkbox for batch delete selection instead of raw checkbox chrome', () => { diff --git a/web-next/components/pages/bulletin-center-surface.tsx b/web-next/components/pages/bulletin-center-surface.tsx index a603c88886..043b8d40bf 100644 --- a/web-next/components/pages/bulletin-center-surface.tsx +++ b/web-next/components/pages/bulletin-center-surface.tsx @@ -65,6 +65,7 @@ export function BulletinCenterSurface({ const activeBulletin = pickSelectedBulletin(data.list.content, selectedId); const facts = buildBulletinFacts(data.list, activeBulletin, t); const activeBulletinId = activeBulletin?.id ?? null; + const currentBulletinName = (activeBulletin?.name || '').trim() || t('common.none'); useEffect(() => { setBatchDeleteIds(current => current.filter(id => data.list.content.some(item => item.id === id))); @@ -309,7 +310,7 @@ export function BulletinCenterSurface({ } }))} variant="card" - ariaLabel="Bulletin navigation" + ariaLabel={t('bulletin.navigation.aria')} /> </div> <div className="border-t border-[var(--ops-border-color)]"> @@ -339,23 +340,23 @@ export function BulletinCenterSurface({ open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)} kicker={t('menu.monitor.bulletin')} - title="确认删除公告" + title={t('bulletin.delete.title')} maxWidthClassName="max-w-xl" footer={ <div className="flex flex-wrap justify-end gap-2"> <Button size="sm" variant="subtle" onClick={() => setDeleteDialogOpen(false)}> - 取消 + {t('common.cancel')} </Button> <Button size="sm" variant="primary" onClick={() => void handleDeleteCurrent()} disabled={!activeBulletin?.id}> - 确认删除 + {t('bulletin.delete.confirm')} </Button> </div> } > <div data-bulletin-delete-confirm="cold-modal" className="space-y-3 text-[12px] leading-6 text-[var(--ops-text-secondary)]"> - <p>删除后该公告看板会从当前工作台移除,指标刷新上下文需要重新选择公告看板。</p> + <p>{t('bulletin.delete.copy')}</p> <div className="rounded-[4px] border border-[var(--ops-border-color)] bg-[var(--ops-surface-raised)] px-3 py-2 font-semibold text-[var(--ops-text-primary)]"> - {activeBulletin?.name || '-'} + {currentBulletinName} </div> </div> </OverlayDialog> diff --git a/web-next/components/pages/bulletin-manage-dialog.tsx b/web-next/components/pages/bulletin-manage-dialog.tsx index 5f18a964cd..4a9077aad0 100644 --- a/web-next/components/pages/bulletin-manage-dialog.tsx +++ b/web-next/components/pages/bulletin-manage-dialog.tsx @@ -67,7 +67,7 @@ export function BulletinManageDialog({ <Input value={draft.monitorIdsText} onChange={event => onDraftChange({ ...draft, monitorIdsText: event.target.value })} - placeholder="1, 2, 3" + placeholder={t('bulletin.monitor.ids.placeholder')} className="h-10 border-[var(--ops-border-color)] bg-[var(--ops-surface-panel)] text-[var(--ops-text-primary)] placeholder:text-[var(--ops-text-tertiary)]" /> diff --git a/web-next/components/pages/bulletin-metrics-table.test.tsx b/web-next/components/pages/bulletin-metrics-table.test.tsx new file mode 100644 index 0000000000..0d5cf4d107 --- /dev/null +++ b/web-next/components/pages/bulletin-metrics-table.test.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { createTranslatorMock } from '../../test/i18n-test-helper'; +import { BulletinMetricsTable } from './bulletin-metrics-table'; + +describe('BulletinMetricsTable', () => { + it('renders fixed table labels and no-data badges through runtime i18n', () => { + const t = createTranslatorMock(); + const html = renderToStaticMarkup( + <BulletinMetricsTable + app="website" + loading={false} + error={null} + t={t} + data={{ + content: [ + { + monitorId: 7, + monitorName: 'Checkout probe', + host: 'checkout.example.com', + metrics: [ + { + name: 'response_time', + fields: [[{ key: 'p95', value: 'NO_DATA' }]] + } + ] + } + ] + }} + /> + ); + + expect(html).toContain('data-bulletin-metrics-table="true"'); + expect(html).toContain('App'); + expect(html).toContain('Host'); + expect(html).toContain('No Data Available'); + expect(t).toHaveBeenCalledWith('bulletin.metrics.column.app'); + expect(t).toHaveBeenCalledWith('bulletin.metrics.column.host'); + expect(t).toHaveBeenCalledWith('bulletin.metrics.no-data'); + }); + + it('renders missing metric values with the localized empty fallback', () => { + const t = createTranslatorMock({ + overrides: { + 'common.none': '无指标值' + } + }); + const html = renderToStaticMarkup( + <BulletinMetricsTable + app="website" + loading={false} + error={null} + t={t} + data={{ + content: [ + { + monitorId: 7, + monitorName: 'Checkout probe', + host: 'checkout.example.com', + metrics: [ + { + name: 'response_time', + fields: [[{ key: 'p95', value: 'NO_DATA' }]] + } + ] + }, + { + monitorId: 8, + monitorName: 'Cart probe', + host: 'cart.example.com', + metrics: [] + } + ] + }} + /> + ); + + expect(html).toContain('Cart probe'); + expect(html).toContain('无指标值'); + expect(t).toHaveBeenCalledWith('common.none'); + }); + + it('renders missing monitor identity with the localized empty fallback', () => { + const t = createTranslatorMock({ + overrides: { + 'common.none': '无监控身份' + } + }); + const html = renderToStaticMarkup( + <BulletinMetricsTable + app="website" + loading={false} + error={null} + t={t} + data={{ + content: [ + { + monitorId: 7, + monitorName: 'Checkout probe', + host: 'checkout.example.com', + metrics: [ + { + name: 'response_time', + fields: [[{ key: 'p95', value: '250', unit: 'ms' }]] + } + ] + }, + { + monitorId: 8, + monitorName: '', + host: '', + metrics: [] + } + ] + }} + /> + ); + + expect(html).toContain('Checkout probe'); + expect((html.match(/无监控身份/g) ?? []).length).toBeGreaterThanOrEqual(2); + expect(t).toHaveBeenCalledWith('common.none'); + }); +}); diff --git a/web-next/components/pages/bulletin-metrics-table.tsx b/web-next/components/pages/bulletin-metrics-table.tsx index b1588ef731..672578e554 100644 --- a/web-next/components/pages/bulletin-metrics-table.tsx +++ b/web-next/components/pages/bulletin-metrics-table.tsx @@ -46,14 +46,15 @@ export function BulletinMetricsTable({ } const columns = buildBulletinMetricColumns(data, app, t); + const emptyMetricValue = t('common.none'); return ( <div className="overflow-x-auto rounded-[6px] border border-[var(--ops-border-color)] bg-[var(--ops-surface-panel)]"> <table className="min-w-full table-auto border-collapse text-[12px]" data-bulletin-metrics-table="true"> <thead> <tr className="border-b border-[var(--ops-border-color)] text-[var(--ops-text-primary)]"> - <th className="min-w-[180px] px-3 py-2 text-center font-semibold">App</th> - <th className="min-w-[180px] px-3 py-2 text-center font-semibold">Host</th> + <th className="min-w-[180px] px-3 py-2 text-center font-semibold">{t('bulletin.metrics.column.app')}</th> + <th className="min-w-[180px] px-3 py-2 text-center font-semibold">{t('bulletin.metrics.column.host')}</th> {columns.map(column => ( <th key={column.key} @@ -77,46 +78,51 @@ export function BulletinMetricsTable({ </tr> </thead> <tbody> - {data.content.map(row => ( - <tr key={`${row.monitorId}-${row.host}`} className="border-b border-[var(--ops-border-color)] align-top last:border-b-0"> - <td className="px-3 py-2 text-center"> - <Link href={`/monitors/${row.monitorId}`} className="font-medium text-[var(--ops-primary)] transition hover:text-[var(--ops-text-primary)]"> - {row.monitorName} - </Link> - </td> - <td className="px-3 py-2 text-center text-[var(--ops-text-secondary)]">{row.host}</td> - {columns.flatMap(column => - column.fields.map(field => { - const values = buildBulletinMetricCellValues(row, column.key, field.key); - return ( - <td key={`${row.monitorId}-${column.key}-${field.key}`} className="border-l border-[var(--ops-border-color)] px-3 py-2"> - {values.length === 0 ? ( - <div className="text-center text-[var(--ops-text-tertiary)]">-</div> - ) : ( - <div className="space-y-1.5"> - {values.map((value, index) => - value.isNoData ? ( - <div key={`${row.monitorId}-${column.key}-${field.key}-${index}`} className="flex justify-center"> - <Badge variant="accent">No Data Available</Badge> - </div> - ) : ( - <div - key={`${row.monitorId}-${column.key}-${field.key}-${index}`} - className="flex items-center justify-between gap-2 whitespace-nowrap rounded-[4px] border border-[var(--ops-border-color)] bg-[var(--ops-surface-raised)] px-2 py-1" - > - <span className="text-[var(--ops-text-primary)]">{value.value}</span> - {value.unit ? <Badge variant="success">{value.unit}</Badge> : null} - </div> - ) - )} - </div> - )} - </td> - ); - }) - )} - </tr> - ))} + {data.content.map(row => { + const monitorName = row.monitorName || emptyMetricValue; + const hostName = row.host || emptyMetricValue; + + return ( + <tr key={`${row.monitorId}-${row.host}`} className="border-b border-[var(--ops-border-color)] align-top last:border-b-0"> + <td className="px-3 py-2 text-center"> + <Link href={`/monitors/${row.monitorId}`} className="font-medium text-[var(--ops-primary)] transition hover:text-[var(--ops-text-primary)]"> + {monitorName} + </Link> + </td> + <td className="px-3 py-2 text-center text-[var(--ops-text-secondary)]">{hostName}</td> + {columns.flatMap(column => + column.fields.map(field => { + const values = buildBulletinMetricCellValues(row, column.key, field.key); + return ( + <td key={`${row.monitorId}-${column.key}-${field.key}`} className="border-l border-[var(--ops-border-color)] px-3 py-2"> + {values.length === 0 ? ( + <div className="text-center text-[var(--ops-text-tertiary)]">{emptyMetricValue}</div> + ) : ( + <div className="space-y-1.5"> + {values.map((value, index) => + value.isNoData ? ( + <div key={`${row.monitorId}-${column.key}-${field.key}-${index}`} className="flex justify-center"> + <Badge variant="accent">{t('bulletin.metrics.no-data')}</Badge> + </div> + ) : ( + <div + key={`${row.monitorId}-${column.key}-${field.key}-${index}`} + className="flex items-center justify-between gap-2 whitespace-nowrap rounded-[4px] border border-[var(--ops-border-color)] bg-[var(--ops-surface-raised)] px-2 py-1" + > + <span className="text-[var(--ops-text-primary)]">{value.value}</span> + {value.unit ? <Badge variant="success">{value.unit}</Badge> : null} + </div> + ) + )} + </div> + )} + </td> + ); + }) + )} + </tr> + ); + })} </tbody> </table> </div> diff --git a/web-next/components/pages/bulletin-monitor-detail.chrome.test.ts b/web-next/components/pages/bulletin-monitor-detail.chrome.test.ts index 0823086cae..5dfd539c6a 100644 --- a/web-next/components/pages/bulletin-monitor-detail.chrome.test.ts +++ b/web-next/components/pages/bulletin-monitor-detail.chrome.test.ts @@ -53,6 +53,7 @@ describe('bulletin center and monitor-detail cold-workbench chrome', () => { const metricTableSource = readFileSync(resolve(process.cwd(), 'components/monitor-detail/monitor-metric-table.tsx'), 'utf8'); const historyChartSource = readFileSync(resolve(process.cwd(), 'components/monitor-detail/history-line-chart.tsx'), 'utf8'); const summaryCardSource = readFileSync(resolve(process.cwd(), 'components/monitor-detail/monitor-summary-card.tsx'), 'utf8'); + const uiSource = readFileSync(resolve(process.cwd(), 'packages/hertzbeat-ui/src/index.tsx'), 'utf8'); expect(bulletinSource).toContain('border-[var(--ops-border-color)]'); expect(bulletinSource).toContain('bg-[var(--ops-surface-panel)]'); @@ -61,23 +62,19 @@ describe('bulletin center and monitor-detail cold-workbench chrome', () => { expect(bulletinSource).toContain('text-[var(--ops-text-secondary)]'); expect(bulletinSource).toContain('text-[var(--ops-text-tertiary)]'); - expect(metricTableSource).toContain('border-[var(--ops-border-color)]'); - expect(metricTableSource).toContain('text-[var(--ops-text-primary)]'); - expect(metricTableSource).toContain('text-[var(--ops-text-secondary)]'); - expect(metricTableSource).toContain('text-[var(--ops-text-tertiary)]'); - expect(metricTableSource).toContain('bg-[var(--ops-surface-panel)]'); - expect(metricTableSource).toContain('bg-[var(--ops-surface-raised)]'); + expect(metricTableSource).toContain('HzDataTable'); + expect(metricTableSource).toContain('data-monitor-metric-table-owner="hertzbeat-ui-data-table"'); + expect(uiSource).toContain('border border-[var(--ops-border-color)]'); + expect(uiSource).toContain('bg-[var(--ops-surface-panel)]'); + expect(uiSource).toContain('text-[var(--ops-text-secondary)]'); - expect(historyChartSource).toContain('border-[var(--ops-border-color)]'); - expect(historyChartSource).toContain('WorkbenchInsetPanel'); - expect(historyChartSource).toContain('bg-[var(--ops-surface-raised)]'); - expect(historyChartSource).toContain('text-[var(--ops-text-primary)]'); - expect(historyChartSource).toContain('text-[var(--ops-text-secondary)]'); - expect(historyChartSource).toContain('text-[var(--ops-text-tertiary)]'); + expect(historyChartSource).toContain('HzChartSurface'); + expect(historyChartSource).toContain('data-monitor-history-line-owner="hertzbeat-ui-chart-surface"'); + expect(uiSource).toContain('export function HzChartSurface'); expect(historyChartSource).not.toContain('className="rounded-[6px] border border-[var(--ops-border-color)] bg-[var(--ops-surface-panel)] p-3"'); - expect(summaryCardSource).toContain('text-[var(--ops-text-tertiary)]'); - expect(summaryCardSource).toContain('WorkbenchBadge'); + expect(summaryCardSource).toContain('HzMonitorBasicSummary'); + expect(uiSource).toContain('export function HzMonitorBasicSummary'); expect(summaryCardSource).not.toContain('inline-flex rounded-[2px] border border-[var(--ops-border-color)] bg-[var(--ops-surface-panel)] px-2.5 py-1 text-xs text-[var(--ops-text-secondary)]'); }); }); diff --git a/web-next/lib/bulletin-center/view-model.test.ts b/web-next/lib/bulletin-center/view-model.test.ts index 52331df673..364e79f5bf 100644 --- a/web-next/lib/bulletin-center/view-model.test.ts +++ b/web-next/lib/bulletin-center/view-model.test.ts @@ -14,6 +14,18 @@ describe('bulletin view model', () => { ]); }); + it('renders missing selected fact name with the localized empty fallback', () => { + expect(buildBulletinFacts({ totalElements: 8, content: [] } as any, { name: ' ' } as any, t)).toContainEqual({ + label: '当前选中', + value: '无' + }); + + expect(buildBulletinFacts({ totalElements: 8, content: [] } as any, null, t)).toContainEqual({ + label: '当前选中', + value: '无' + }); + }); + it('builds bulletin rows', () => { expect( buildBulletinRows( @@ -27,8 +39,8 @@ describe('bulletin view model', () => { { key: '7', title: 'Ops board', - copy: 'checkout · monitors 2', - meta: '2026-04-10 18:00:00 · creator ops' + copy: 'checkout · 监控对象 2', + meta: '2026-04-10 18:00:00 · 创建人 ops' } ]); }); @@ -41,11 +53,60 @@ describe('bulletin view model', () => { () => '2026-04-10 18:00:00' ) ).toEqual([ - { title: 'Ops board', copy: 'checkout · monitors 2', meta: 'id 7' }, - { title: 'fields', copy: '1', meta: '更新时间 2026-04-10 18:00:00' } + { title: 'Ops board', copy: 'checkout · 监控对象 2', meta: 'id 7' }, + { title: '字段', copy: '1', meta: '更新时间 2026-04-10 18:00:00' } ]); }); + it('renders missing bulletin facts with the localized empty fallback', () => { + expect( + buildBulletinRows( + [{ id: 9, name: 'Fallback board', app: ' ', monitorIds: [], creator: '' }] as any, + t, + () => '2026-04-10 18:00:00' + ) + ).toEqual([ + { + key: '9', + title: 'Fallback board', + copy: '无 · 监控对象 0', + meta: '2026-04-10 18:00:00 · 创建人 无' + } + ]); + + expect( + buildBulletinSelectionRows( + { id: 9, name: 'Fallback board', app: ' ', monitorIds: [], fields: {} } as any, + t, + () => '2026-04-10 18:00:00' + )[0] + ).toEqual({ title: 'Fallback board', copy: '无 · 监控对象 0', meta: 'id 9' }); + + expect(buildBulletinSelectionRows(null, t, () => '2026-04-10 18:00:00')[0]).toEqual({ + title: '未选择公告', + copy: '从公告列表选择一个看板后查看指标上下文。', + meta: '无' + }); + }); + + it('renders missing bulletin titles with the localized empty fallback', () => { + expect( + buildBulletinRows( + [{ id: 9, name: ' ', app: 'checkout', monitorIds: [], creator: 'ops' }] as any, + t, + () => '2026-04-10 18:00:00' + )[0].title + ).toBe('无'); + + expect( + buildBulletinSelectionRows( + { id: 9, name: '', app: 'checkout', monitorIds: [], fields: {} } as any, + t, + () => '2026-04-10 18:00:00' + )[0].title + ).toBe('无'); + }); + it('picks the explicit selection and falls back to the first row', () => { expect(pickSelectedBulletin([{ id: 7 }, { id: 8 }] as any, 8)).toEqual({ id: 8 }); expect(pickSelectedBulletin([{ id: 7 }, { id: 8 }] as any, 99)).toEqual({ id: 7 }); @@ -53,7 +114,7 @@ describe('bulletin view model', () => { }); it('builds the current query label and selected bulletin json', () => { - expect(buildBulletinCurrentQueryLabel(' ', t)).toBe('all bulletins'); + expect(buildBulletinCurrentQueryLabel(' ', t)).toBe('全部公告'); expect(buildBulletinCurrentQueryLabel('checkout', t)).toBe('checkout'); expect(buildSelectedBulletinJson({ id: 7, name: 'Ops board' } as any)).toBe('{\n "id": 7,\n "name": "Ops board"\n}'); expect(buildSelectedBulletinJson(null)).toBeNull(); @@ -84,14 +145,14 @@ describe('bulletin view model', () => { expect(buildBulletinMetricsState(null, null, t)).toEqual({ kind: 'empty', - title: 'No metrics', - copy: 'No metrics are available yet.', + title: '暂无指标', + copy: '暂无可用指标。', tone: 'default' }); expect(buildBulletinMetricsState(null, 'boom', t)).toEqual({ kind: 'empty', - title: 'Metrics unavailable', + title: '指标不可用', copy: 'boom', tone: 'danger' }); @@ -155,10 +216,10 @@ describe('bulletin view model', () => { fieldsJson: '{\n "cpu": [\n "usage"\n ]\n}' }); - expect(validateBulletinForm({ name: '', app: '', monitorIdsText: '', fieldsJson: '{}' }, t)).toBe('Bulletin name is required'); - expect(validateBulletinForm({ name: 'Ops', app: '', monitorIdsText: '', fieldsJson: '{}' }, t)).toBe('App is required'); - expect(validateBulletinForm({ name: 'Ops', app: 'mysql', monitorIdsText: '', fieldsJson: '{}' }, t)).toBe('At least one monitor id is required'); - expect(validateBulletinForm({ name: 'Ops', app: 'mysql', monitorIdsText: '1,2', fieldsJson: '{oops' }, t)).toBe('bulletin.validation.fields'); + expect(validateBulletinForm({ name: '', app: '', monitorIdsText: '', fieldsJson: '{}' }, t)).toBe('公告看板名称为必填项'); + expect(validateBulletinForm({ name: 'Ops', app: '', monitorIdsText: '', fieldsJson: '{}' }, t)).toBe('应用为必填项'); + expect(validateBulletinForm({ name: 'Ops', app: 'mysql', monitorIdsText: '', fieldsJson: '{}' }, t)).toBe('至少需要填写一个监控 ID'); + expect(validateBulletinForm({ name: 'Ops', app: 'mysql', monitorIdsText: '1,2', fieldsJson: '{oops' }, t)).toBe('指标字段 JSON 格式不正确'); expect(validateBulletinForm({ name: 'Ops', app: 'mysql', monitorIdsText: '1,2', fieldsJson: '{}' }, t)).toBeNull(); }); }); diff --git a/web-next/lib/bulletin-center/view-model.ts b/web-next/lib/bulletin-center/view-model.ts index 004d287c59..64b06e71ab 100644 --- a/web-next/lib/bulletin-center/view-model.ts +++ b/web-next/lib/bulletin-center/view-model.ts @@ -6,33 +6,50 @@ type Translator = (key: string, params?: Record<string, string | number | null | const BULLETIN_REFRESH_PRESETS = [10, 30, 60, 300, -1] as const; +function formatBulletinFact(value: string | number | null | undefined, emptyValue: string) { + const text = value == null ? '' : String(value).trim(); + return text || emptyValue; +} + export function buildBulletinFacts(list: PageResult<Bulletin>, selected: Bulletin | null, t: Translator) { + const emptyValue = t('common.none'); + return [ { label: t('common.workspace'), value: 'bulletin' }, { label: t('common.total'), value: String(list.totalElements || 0) }, { label: t('common.current-page-count'), value: String(list.content?.length || 0) }, - { label: t('bulletin.facts.selected'), value: selected?.name || t('common.none') } + { label: t('bulletin.facts.selected'), value: formatBulletinFact(selected?.name, emptyValue) } ]; } export function buildBulletinRows(items: Bulletin[], t: Translator, formatTime: (value?: number | string | null) => string) { - return items.map(item => ({ - key: String(item.id), - title: item.name, - copy: `${item.app || '-'} · ${t('bulletin.list.monitors')} ${(item.monitorIds || []).length}`, - meta: `${formatTime(item.gmtUpdate || item.gmtCreate || null)} · ${t('bulletin.list.creator')} ${item.creator || '-'}` - })); + const emptyValue = t('common.none'); + + return items.map(item => { + const rowTitle = formatBulletinFact(item.name, emptyValue); + + return { + key: String(item.id), + title: rowTitle, + copy: `${formatBulletinFact(item.app, emptyValue)} · ${t('bulletin.list.monitors')} ${(item.monitorIds || []).length}`, + meta: `${formatTime(item.gmtUpdate || item.gmtCreate || null)} · ${t('bulletin.list.creator')} ${formatBulletinFact(item.creator, emptyValue)}` + }; + }); } export function buildBulletinSelectionRows(selected: Bulletin | null, t: Translator, formatTime: (value?: number | string | null) => string) { + const emptyValue = t('common.none'); + if (!selected) { - return [{ title: t('bulletin.selected.empty.title'), copy: t('bulletin.selected.empty.copy'), meta: '-' }]; + return [{ title: t('bulletin.selected.empty.title'), copy: t('bulletin.selected.empty.copy'), meta: emptyValue }]; } + const selectedTitle = formatBulletinFact(selected.name, emptyValue); + return [ { - title: selected.name, - copy: `${selected.app || '-'} · ${t('bulletin.list.monitors')} ${(selected.monitorIds || []).length}`, + title: selectedTitle, + copy: `${formatBulletinFact(selected.app, emptyValue)} · ${t('bulletin.list.monitors')} ${(selected.monitorIds || []).length}`, meta: `id ${selected.id}` }, { --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
