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]


Reply via email to