This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 4c66ebfc3 fix(queue-browser): localize the queue browser (#6482)
4c66ebfc3 is described below

commit 4c66ebfc367361722c102fec4d1fc42ae8876b73
Author: 0 <[email protected]>
AuthorDate: Thu Oct 15 20:21:35 2026 +0800

    fix(queue-browser): localize the queue browser (#6482)
    
    * fix(web): take the Queue Browser strings through the translation table
    
    Every user-facing string in the queue browser panel was hardcoded
    Chinese: the topic placeholder, the load button, both empty-state hints,
    the column titles, the view action, the queue summary, the detail labels,
    the no-message placeholder and both error toasts. A viewer who switched
    the display language to English kept getting Chinese on this panel.
    
    The panel now resolves through t(): it reuses messagePage.topicPlaceholder,
    messagePage.storeTime, messagePage.size, common.actions and common.view,
    and adds a queueBrowser.* group for the panel-specific strings, with a
    test that renders the panel under the English locale.
    
    Fixes #5220
    
    * test(queue-browser): cover the six queueBrowser keys that had no assertion
    
    Only queueBrowser.loadQueues and queueBrowser.emptyHint were asserted, so
    dropping loadQueuesFailed, pullFailed, offsetRange, queueSummary,
    detailEmptyHint or noMessageAtOffset from the table left every test green.
    Add four cases that render the panel under the en locale: the queue table
    headers plus the summary with both counters interpolated to real numbers,
    both error toasts through a non-Error rejection so the dictionary fallback
    is what fires, and the pulled-message detail covering the empty-offset
    placeholder and the formatBytes size cell.
    
    Also re-run Prettier over QueueBrowser.tsx: the upstream merge left the
    MessageProperties element misindented, which fails the format check.
    
    ---------
    
    Co-authored-by: lizhimins <[email protected]>
---
 web/src/components/QueueBrowser.tsx                | 456 +++++++++++----------
 web/src/components/__tests__/QueueBrowser.test.tsx | 160 +++++++-
 web/src/i18n/translations.ts                       |  22 +
 3 files changed, 414 insertions(+), 224 deletions(-)

diff --git a/web/src/components/QueueBrowser.tsx 
b/web/src/components/QueueBrowser.tsx
index 9de3120b5..df00a390e 100644
--- a/web/src/components/QueueBrowser.tsx
+++ b/web/src/components/QueueBrowser.tsx
@@ -34,8 +34,9 @@ import {
 import { CloseOutlined, SearchOutlined } from '@ant-design/icons';
 import type { MessageRecord, QueueOffset } from '../api/message';
 import { getQueueOffsets, pullMessageAtOffset } from '../api/message';
+import { useLang } from '../i18n/LangContext';
 import MessageProperties from './MessageProperties';
-import { formatTimeMs } from '../utils/format';
+import { formatBytes, formatTimeMs } from '../utils/format';
 
 const { Text, Paragraph } = Typography;
 
@@ -51,6 +52,7 @@ export interface PulledEntry {
 }
 
 export const useQueueBrowser = (instanceId?: string) => {
+  const { t } = useLang();
   const [topic, setTopic] = useState<string | undefined>();
   const [queues, setQueues] = useState<QueueOffset[]>([]);
   const [loading, setLoading] = useState(false);
@@ -103,7 +105,7 @@ export const useQueueBrowser = (instanceId?: string) => {
       setOffsets(initial);
     } catch (err) {
       if (requestId === requestSeqRef.current) {
-        message.error(err instanceof Error ? err.message : '加载队列信息失败');
+        message.error(err instanceof Error ? err.message : 
t('queueBrowser.loadQueuesFailed'));
       }
     } finally {
       if (requestId === requestSeqRef.current) {
@@ -111,7 +113,7 @@ export const useQueueBrowser = (instanceId?: string) => {
         setLoading(false);
       }
     }
-  }, [instanceId, topic]);
+  }, [instanceId, topic, t]);
 
   const handlePull = async (queue: QueueOffset) => {
     if (!instanceId || !topic) return;
@@ -139,7 +141,7 @@ export const useQueueBrowser = (instanceId?: string) => {
       ]);
     } catch (err) {
       if (ownsPull()) {
-        message.error(err instanceof Error ? err.message : '拉取消息失败');
+        message.error(err instanceof Error ? err.message : 
t('queueBrowser.pullFailed'));
       }
     } finally {
       if (ownsPull()) {
@@ -184,228 +186,236 @@ export const QueueBrowserControls = ({
   state,
   topicOptions,
   topicLoading,
-}: ControlsProps) => (
-  <Flex gap={12} align="center">
-    <Select
-      showSearch
-      allowClear
-      placeholder="选择 Topic"
-      value={state.topic}
-      onChange={state.setTopic}
-      options={topicOptions}
-      loading={topicLoading}
-      style={{ width: 280 }}
-    />
-    <Button
-      type="primary"
-      icon={<SearchOutlined />}
-      disabled={!instanceId || !state.topic}
-      loading={state.loading}
-      onClick={() => void state.loadQueues()}
-    >
-      加载队列
-    </Button>
-  </Flex>
-);
-
-export const QueueBrowserResults = ({ state }: { state: QueueBrowserState }) 
=> (
-  <Card>
-    {state.loading ? (
-      <Flex justify="center" style={{ padding: 32 }}>
-        <Spin />
-      </Flex>
-    ) : state.queues.length === 0 ? (
-      <Empty
-        image={Empty.PRESENTED_IMAGE_SIMPLE}
-        description="选择 Topic 并点击「加载队列」,按队列浏览消息"
-        style={{ padding: '32px 0' }}
+}: ControlsProps) => {
+  const { t } = useLang();
+  return (
+    <Flex gap={12} align="center">
+      <Select
+        showSearch
+        allowClear
+        placeholder={t('messagePage.topicPlaceholder')}
+        value={state.topic}
+        onChange={state.setTopic}
+        options={topicOptions}
+        loading={topicLoading}
+        style={{ width: 280 }}
       />
-    ) : (
-      <Flex gap={16} align="flex-start">
-        {/* 左侧:队列表格 */}
-        <div style={{ width: '50%', flexShrink: 0 }}>
-          <Table<QueueOffset>
-            rowKey={(r) => `${r.brokerName}-${r.queueId}`}
-            dataSource={state.queues}
-            size="small"
-            pagination={false}
-            columns={[
-              {
-                title: 'Broker',
-                dataIndex: 'brokerName',
-                width: 180,
-                ellipsis: { showTitle: false },
-                render: (v: string) => (
-                  <Tooltip title={v}>
-                    <Text strong style={{ fontSize: 14 }}>
-                      {v}
-                    </Text>
-                  </Tooltip>
-                ),
-              },
-              {
-                title: 'Queue',
-                dataIndex: 'queueId',
-                width: 50,
-                align: 'center',
-                render: (v: number) => <Text style={{ fontSize: 14 
}}>{v}</Text>,
-              },
-              {
-                title: 'Offset 范围',
-                key: 'offset',
-                width: 170,
-                render: (_: unknown, record: QueueOffset) => {
-                  const key = `${record.brokerName}-${record.queueId}`;
-                  const currentOffset = state.offsets[key] ?? record.minOffset;
-                  return (
-                    <Flex align="center" gap={8}>
-                      <Text type="secondary" style={{ fontSize: 14, 
flexShrink: 0 }}>
-                        {record.minOffset}
-                      </Text>
-                      <Slider
-                        style={{ flex: 1, margin: 0 }}
-                        min={record.minOffset}
-                        max={
-                          record.maxOffset > record.minOffset
-                            ? record.maxOffset - 1
-                            : record.minOffset
-                        }
-                        value={currentOffset}
-                        onChange={(value) =>
-                          state.setOffsets((prev) => ({ ...prev, [key]: value 
}))
-                        }
-                        tooltip={{ formatter: (v) => `offset: ${v}` }}
-                      />
-                      <Text code style={{ fontSize: 14, flexShrink: 0 }}>
-                        {currentOffset}
+      <Button
+        type="primary"
+        icon={<SearchOutlined />}
+        disabled={!instanceId || !state.topic}
+        loading={state.loading}
+        onClick={() => void state.loadQueues()}
+      >
+        {t('queueBrowser.loadQueues')}
+      </Button>
+    </Flex>
+  );
+};
+
+export const QueueBrowserResults = ({ state }: { state: QueueBrowserState }) 
=> {
+  const { t } = useLang();
+  return (
+    <Card>
+      {state.loading ? (
+        <Flex justify="center" style={{ padding: 32 }}>
+          <Spin />
+        </Flex>
+      ) : state.queues.length === 0 ? (
+        <Empty
+          image={Empty.PRESENTED_IMAGE_SIMPLE}
+          description={t('queueBrowser.emptyHint')}
+          style={{ padding: '32px 0' }}
+        />
+      ) : (
+        <Flex gap={16} align="flex-start">
+          {/* 左侧:队列表格 */}
+          <div style={{ width: '50%', flexShrink: 0 }}>
+            <Table<QueueOffset>
+              rowKey={(r) => `${r.brokerName}-${r.queueId}`}
+              dataSource={state.queues}
+              size="small"
+              pagination={false}
+              columns={[
+                {
+                  title: 'Broker',
+                  dataIndex: 'brokerName',
+                  width: 180,
+                  ellipsis: { showTitle: false },
+                  render: (v: string) => (
+                    <Tooltip title={v}>
+                      <Text strong style={{ fontSize: 14 }}>
+                        {v}
                       </Text>
-                    </Flex>
-                  );
+                    </Tooltip>
+                  ),
                 },
-              },
-              {
-                title: '操作',
-                key: 'action',
-                width: 70,
-                align: 'center',
-                render: (_: unknown, record: QueueOffset) => {
-                  const key = `${record.brokerName}-${record.queueId}`;
-                  return (
-                    <Button
-                      size="small"
-                      type="primary"
-                      loading={state.pulling.has(key)}
-                      onClick={() => void state.handlePull(record)}
-                    >
-                      查看
-                    </Button>
-                  );
+                {
+                  title: 'Queue',
+                  dataIndex: 'queueId',
+                  width: 50,
+                  align: 'center',
+                  render: (v: number) => <Text style={{ fontSize: 14 
}}>{v}</Text>,
                 },
-              },
-            ]}
-          />
-          <Text type="secondary" style={{ display: 'block', marginTop: 8, 
fontSize: 14 }}>
-            共 {state.queues.length} 个队列,总消息量{' '}
-            {state.queues.reduce((sum, q) => sum + (q.maxOffset - 
q.minOffset), 0)} 条
-          </Text>
-        </div>
-
-        {/* 右侧:消息详情(2 列,可多条并存) */}
-        <div style={{ flex: 1, minWidth: 0 }}>
-          {state.entries.length === 0 ? (
-            <Empty
-              image={Empty.PRESENTED_IMAGE_SIMPLE}
-              description="点击左侧「查看」,消息详情将显示在这里"
-              style={{ padding: '32px 0' }}
+                {
+                  title: t('queueBrowser.offsetRange'),
+                  key: 'offset',
+                  width: 170,
+                  render: (_: unknown, record: QueueOffset) => {
+                    const key = `${record.brokerName}-${record.queueId}`;
+                    const currentOffset = state.offsets[key] ?? 
record.minOffset;
+                    return (
+                      <Flex align="center" gap={8}>
+                        <Text type="secondary" style={{ fontSize: 14, 
flexShrink: 0 }}>
+                          {record.minOffset}
+                        </Text>
+                        <Slider
+                          style={{ flex: 1, margin: 0 }}
+                          min={record.minOffset}
+                          max={
+                            record.maxOffset > record.minOffset
+                              ? record.maxOffset - 1
+                              : record.minOffset
+                          }
+                          value={currentOffset}
+                          onChange={(value) =>
+                            state.setOffsets((prev) => ({ ...prev, [key]: 
value }))
+                          }
+                          tooltip={{ formatter: (v) => `offset: ${v}` }}
+                        />
+                        <Text code style={{ fontSize: 14, flexShrink: 0 }}>
+                          {currentOffset}
+                        </Text>
+                      </Flex>
+                    );
+                  },
+                },
+                {
+                  title: t('common.actions'),
+                  key: 'action',
+                  width: 70,
+                  align: 'center',
+                  render: (_: unknown, record: QueueOffset) => {
+                    const key = `${record.brokerName}-${record.queueId}`;
+                    return (
+                      <Button
+                        size="small"
+                        type="primary"
+                        loading={state.pulling.has(key)}
+                        onClick={() => void state.handlePull(record)}
+                      >
+                        {t('common.view')}
+                      </Button>
+                    );
+                  },
+                },
+              ]}
             />
-          ) : (
-            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', 
gap: 12 }}>
-              {state.entries.map((entry) => (
-                <Card
-                  key={entry.key}
-                  size="small"
-                  style={{ borderRadius: 8 }}
-                  title={
-                    <Space size={4}>
-                      <Tag color="blue" style={{ marginInlineEnd: 0 }}>
-                        {entry.key}
-                      </Tag>
-                      <Text type="secondary" style={{ fontSize: 14 }}>
-                        @ {entry.offset}
-                      </Text>
-                    </Space>
-                  }
-                  extra={
-                    <Button
-                      type="text"
-                      size="small"
-                      icon={<CloseOutlined />}
-                      onClick={() => state.closeEntry(entry.key)}
-                    />
-                  }
-                >
-                  {entry.message ? (
-                    <>
-                      <Descriptions column={1} size="small">
-                        <Descriptions.Item label="Message ID">
-                          <Paragraph
-                            copyable
-                            style={{ marginBottom: 0, fontFamily: 'monospace', 
fontSize: 14 }}
-                          >
-                            {entry.message.msgId}
-                          </Paragraph>
-                        </Descriptions.Item>
-                        <Descriptions.Item label="Tag">
-                          <Tag>{entry.message.tag || '-'}</Tag>
-                        </Descriptions.Item>
-                        <Descriptions.Item label="Key">
-                          <span style={{ fontFamily: 'monospace', fontSize: 14 
}}>
-                            {entry.message.key || '-'}
-                          </span>
-                        </Descriptions.Item>
-                        <Descriptions.Item label="存储时间">
-                          <span style={{ fontFamily: 'monospace', fontSize: 14 
}}>
-                            {formatTimeMs(entry.message.storeTime)}
-                          </span>
-                        </Descriptions.Item>
-                        <Descriptions.Item label="大小">
-                          {entry.message.size} bytes
-                        </Descriptions.Item>
-                        <Descriptions.Item label="Born Host">
-                          {entry.message.bornHost || '-'}
-                        </Descriptions.Item>
-                      </Descriptions>
-                      {entry.message.body && (
-                        <Card size="small" title="Body" style={{ marginTop: 12 
}}>
-                          <pre
-                            style={{
-                              maxHeight: 160,
-                              overflow: 'auto',
-                              fontSize: 14,
-                              fontFamily: 'monospace',
-                              whiteSpace: 'pre-wrap',
-                              wordBreak: 'break-all',
-                              margin: 0,
-                            }}
-                          >
-                            {entry.message.body}
-                          </pre>
-                        </Card>
-                      )}
-                      <MessageProperties
-                        properties={entry.message.properties}
-                        propertiesTruncated={entry.message.propertiesTruncated}
+            <Text type="secondary" style={{ display: 'block', marginTop: 8, 
fontSize: 14 }}>
+              {t('queueBrowser.queueSummary', {
+                queues: state.queues.length,
+                messages: state.queues.reduce((sum, q) => sum + (q.maxOffset - 
q.minOffset), 0),
+              })}
+            </Text>
+          </div>
+
+          {/* 右侧:消息详情(2 列,可多条并存) */}
+          <div style={{ flex: 1, minWidth: 0 }}>
+            {state.entries.length === 0 ? (
+              <Empty
+                image={Empty.PRESENTED_IMAGE_SIMPLE}
+                description={t('queueBrowser.detailEmptyHint')}
+                style={{ padding: '32px 0' }}
+              />
+            ) : (
+              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', 
gap: 12 }}>
+                {state.entries.map((entry) => (
+                  <Card
+                    key={entry.key}
+                    size="small"
+                    style={{ borderRadius: 8 }}
+                    title={
+                      <Space size={4}>
+                        <Tag color="blue" style={{ marginInlineEnd: 0 }}>
+                          {entry.key}
+                        </Tag>
+                        <Text type="secondary" style={{ fontSize: 14 }}>
+                          @ {entry.offset}
+                        </Text>
+                      </Space>
+                    }
+                    extra={
+                      <Button
+                        type="text"
+                        size="small"
+                        icon={<CloseOutlined />}
+                        onClick={() => state.closeEntry(entry.key)}
                       />
-                    </>
-                  ) : (
-                    <Text type="secondary">该 offset 处无消息</Text>
-                  )}
-                </Card>
-              ))}
-            </div>
-          )}
-        </div>
-      </Flex>
-    )}
-  </Card>
-);
+                    }
+                  >
+                    {entry.message ? (
+                      <>
+                        <Descriptions column={1} size="small">
+                          <Descriptions.Item label="Message ID">
+                            <Paragraph
+                              copyable
+                              style={{ marginBottom: 0, fontFamily: 
'monospace', fontSize: 14 }}
+                            >
+                              {entry.message.msgId}
+                            </Paragraph>
+                          </Descriptions.Item>
+                          <Descriptions.Item label="Tag">
+                            <Tag>{entry.message.tag || '-'}</Tag>
+                          </Descriptions.Item>
+                          <Descriptions.Item label="Key">
+                            <span style={{ fontFamily: 'monospace', fontSize: 
14 }}>
+                              {entry.message.key || '-'}
+                            </span>
+                          </Descriptions.Item>
+                          <Descriptions.Item 
label={t('messagePage.storeTime')}>
+                            <span style={{ fontFamily: 'monospace', fontSize: 
14 }}>
+                              {formatTimeMs(entry.message.storeTime)}
+                            </span>
+                          </Descriptions.Item>
+                          <Descriptions.Item label={t('messagePage.size')}>
+                            {formatBytes(entry.message.size)}
+                          </Descriptions.Item>
+                          <Descriptions.Item label="Born Host">
+                            {entry.message.bornHost || '-'}
+                          </Descriptions.Item>
+                        </Descriptions>
+                        {entry.message.body && (
+                          <Card size="small" title="Body" style={{ marginTop: 
12 }}>
+                            <pre
+                              style={{
+                                maxHeight: 160,
+                                overflow: 'auto',
+                                fontSize: 14,
+                                fontFamily: 'monospace',
+                                whiteSpace: 'pre-wrap',
+                                wordBreak: 'break-all',
+                                margin: 0,
+                              }}
+                            >
+                              {entry.message.body}
+                            </pre>
+                          </Card>
+                        )}
+                        <MessageProperties
+                          properties={entry.message.properties}
+                          
propertiesTruncated={entry.message.propertiesTruncated}
+                        />
+                      </>
+                    ) : (
+                      <Text 
type="secondary">{t('queueBrowser.noMessageAtOffset')}</Text>
+                    )}
+                  </Card>
+                ))}
+              </div>
+            )}
+          </div>
+        </Flex>
+      )}
+    </Card>
+  );
+};
diff --git a/web/src/components/__tests__/QueueBrowser.test.tsx 
b/web/src/components/__tests__/QueueBrowser.test.tsx
index e59a440b3..7cd126d81 100644
--- a/web/src/components/__tests__/QueueBrowser.test.tsx
+++ b/web/src/components/__tests__/QueueBrowser.test.tsx
@@ -30,7 +30,8 @@ import { afterEach, beforeAll, beforeEach, describe, expect, 
it, vi } from 'vite
 import { StrictMode } from 'react';
 import type { MessageRecord, QueueOffset } from '../../api/message';
 import { getQueueOffsets, pullMessageAtOffset } from '../../api/message';
-import { QueueBrowserResults, useQueueBrowser } from '../QueueBrowser';
+import { QueueBrowserControls, QueueBrowserResults, useQueueBrowser } from 
'../QueueBrowser';
+import { LangProvider } from '../../i18n/LangContext';
 
 vi.mock('../../api/message', () => ({
   getQueueOffsets: vi.fn(),
@@ -503,3 +504,160 @@ describe('QueueBrowser request ownership', () => {
     ).toBeInTheDocument();
   });
 });
+
+describe('QueueBrowser localization', () => {
+  // The queue browser hardcoded Chinese strings regardless of the display 
language (#4941 fixed
+  // the composer font size; this panel regressed the other way). The strings 
now go through the
+  // translation table like every other surface.
+  it('renders the controls and empty states in the display language', () => {
+    window.localStorage.setItem('rocketmq-studio-language', 'en');
+
+    // The hook itself consumes the language context, so it must render inside 
the provider.
+    const Inner = () => {
+      const state = useQueueBrowser(undefined);
+      return (
+        <>
+          <QueueBrowserControls
+            instanceId={undefined}
+            state={state}
+            topicOptions={[]}
+            topicLoading={false}
+          />
+          <QueueBrowserResults state={state} />
+        </>
+      );
+    };
+    render(
+      <LangProvider>
+        <Inner />
+      </LangProvider>,
+    );
+
+    // The search icon contributes to the accessible name, so match on the 
label text.
+    expect(screen.getByRole('button', { name: /Load Queues/ 
})).toBeInTheDocument();
+    expect(
+      screen.getByText('Select a topic and click "Load Queues" to browse 
messages by queue'),
+    ).toBeInTheDocument();
+    expect(screen.getByText('Select a topic')).toBeInTheDocument();
+    // The empty queue list renders before the detail pane, so only its hint 
is visible here;
+    // the point is that no hardcoded Chinese remains on the rendered surface.
+    expect(screen.queryByText('加载队列')).not.toBeInTheDocument();
+    expect(
+      screen.queryByText('选择 Topic 并点击「加载队列」,按队列浏览消息'),
+    ).not.toBeInTheDocument();
+  });
+
+  // The remaining queueBrowser.* keys only surface once a topic has actually 
been loaded, so they
+  // need a probe that drives the hook (and therefore the language context) 
under the en locale.
+  const renderEnglishResults = () => {
+    window.localStorage.setItem('rocketmq-studio-language', 'en');
+    const Inner = () => {
+      const state = useQueueBrowser('instance-a');
+      const firstQueue = state.queues[0];
+      return (
+        <div>
+          <button type="button" onClick={() => state.setTopic('topic-a')}>
+            topic-a
+          </button>
+          <button type="button" onClick={() => void state.loadQueues()}>
+            load
+          </button>
+          <button
+            type="button"
+            disabled={!firstQueue}
+            onClick={() => firstQueue && void state.handlePull(firstQueue)}
+          >
+            pull
+          </button>
+          <QueueBrowserResults state={state} />
+        </div>
+      );
+    };
+    render(
+      <LangProvider>
+        <Inner />
+      </LangProvider>,
+    );
+    const user = userEvent.setup();
+    const loadTopic = async () => {
+      await user.click(screen.getByRole('button', { name: 'topic-a' }));
+      await user.click(screen.getByRole('button', { name: 'load' }));
+      await screen.findByText('broker-a');
+    };
+    return { user, loadTopic };
+  };
+
+  it('renders the loaded queue table and its summary in the display language', 
async () => {
+    vi.mocked(getQueueOffsets).mockResolvedValue([
+      { brokerName: 'broker-a', queueId: 0, minOffset: 0, maxOffset: 3 },
+      { brokerName: 'broker-b', queueId: 1, minOffset: 5, maxOffset: 12 },
+    ]);
+    const { loadTopic } = renderEnglishResults();
+
+    await loadTopic();
+
+    expect(screen.getByText('Offset Range')).toBeInTheDocument();
+    // The summary interpolates both counters, so assert the substituted 
numbers: a key missing
+    // from the dictionary would render the raw `{queues} queues, {messages} 
messages in total`.
+    expect(screen.getByText('2 queues, 10 messages in 
total')).toBeInTheDocument();
+    expect(
+      screen.getByText('Click "View" on the left; message details appear 
here'),
+    ).toBeInTheDocument();
+    expect(screen.queryByText('Offset 范围')).not.toBeInTheDocument();
+    expect(screen.queryByText(/共 \d+ 个队列/)).not.toBeInTheDocument();
+    expect(screen.queryByText('点击左侧「查看」,消息详情将显示在这里')).not.toBeInTheDocument();
+  });
+
+  it('announces a queue load failure in the display language', async () => {
+    const errorToast = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+    // The toast only falls back to the dictionary when the rejection carries 
no message of its
+    // own, which is what a non-Error provider failure looks like.
+    vi.mocked(getQueueOffsets).mockRejectedValue('provider rejected the 
request');
+    const { user } = renderEnglishResults();
+
+    await user.click(screen.getByRole('button', { name: 'topic-a' }));
+    await user.click(screen.getByRole('button', { name: 'load' }));
+
+    await waitFor(() =>
+      expect(errorToast).toHaveBeenCalledWith('Failed to load queue 
information'),
+    );
+    expect(errorToast).not.toHaveBeenCalledWith('加载队列信息失败');
+    errorToast.mockRestore();
+  });
+
+  it('announces a pull failure in the display language', async () => {
+    const errorToast = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+    vi.mocked(getQueueOffsets).mockResolvedValue([queue('broker-a')]);
+    vi.mocked(pullMessageAtOffset).mockRejectedValue('provider rejected the 
pull');
+    const { loadTopic, user } = renderEnglishResults();
+
+    await loadTopic();
+    await user.click(screen.getByRole('button', { name: 'pull' }));
+
+    await waitFor(() => expect(errorToast).toHaveBeenCalledWith('Failed to 
pull the message'));
+    expect(errorToast).not.toHaveBeenCalledWith('拉取消息失败');
+    errorToast.mockRestore();
+  });
+
+  it('renders the pulled message detail in the display language', async () => {
+    vi.mocked(getQueueOffsets).mockResolvedValue([queue('broker-a')]);
+    vi.mocked(pullMessageAtOffset)
+      .mockResolvedValueOnce(null)
+      .mockResolvedValueOnce({ ...messageRecord('sized-message'), size: 1024 
});
+    const { loadTopic, user } = renderEnglishResults();
+
+    await loadTopic();
+
+    await user.click(screen.getByRole('button', { name: 'pull' }));
+    // An offset with nothing behind it renders the placeholder instead of an 
empty detail card.
+    await screen.findByText('No message at this offset');
+    expect(screen.queryByText('该 offset 处无消息')).not.toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: 'pull' }));
+    await screen.findByText('sized-message');
+    // The size cell goes through the shared byte formatter, so it reads as a 
scaled unit rather
+    // than a raw byte count.
+    expect(screen.getByText('1.0 KB')).toBeInTheDocument();
+    expect(screen.queryByText('1024 bytes')).not.toBeInTheDocument();
+  });
+});
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index d101d2f76..2f0087186 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -855,6 +855,28 @@ const translations: Record<string, Record<Lang, string>> = 
{
   },
   'messagePage.downloadSuccess': { zh: '消息下载成功', en: 'Message downloaded' },
 
+  // ─── Queue Browser ───
+  'queueBrowser.loadQueues': { zh: '加载队列', en: 'Load Queues' },
+  'queueBrowser.loadQueuesFailed': {
+    zh: '加载队列信息失败',
+    en: 'Failed to load queue information',
+  },
+  'queueBrowser.pullFailed': { zh: '拉取消息失败', en: 'Failed to pull the message' 
},
+  'queueBrowser.emptyHint': {
+    zh: '选择 Topic 并点击「加载队列」,按队列浏览消息',
+    en: 'Select a topic and click "Load Queues" to browse messages by queue',
+  },
+  'queueBrowser.offsetRange': { zh: 'Offset 范围', en: 'Offset Range' },
+  'queueBrowser.queueSummary': {
+    zh: '共 {queues} 个队列,总消息量 {messages} 条',
+    en: '{queues} queues, {messages} messages in total',
+  },
+  'queueBrowser.detailEmptyHint': {
+    zh: '点击左侧「查看」,消息详情将显示在这里',
+    en: 'Click "View" on the left; message details appear here',
+  },
+  'queueBrowser.noMessageAtOffset': { zh: '该 offset 处无消息', en: 'No message at 
this offset' },
+
   // ─── Message Query History ───
   'messageHistory.title': { zh: '服务端查询历史', en: 'Server Query History' },
   'messageHistory.messageQueries': { zh: '消息查询', en: 'Message Queries' },

Reply via email to