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 6dd02d275 feat(web): surface the naming rule on the create forms and 
document the model (#7354)
6dd02d275 is described below

commit 6dd02d2759e7c4bb6b7ba34f6a768628f03377f1
Author: lizhimins <[email protected]>
AuthorDate: Mon Oct 26 15:05:15 2026 +0800

    feat(web): surface the naming rule on the create forms and document the 
model (#7354)
    
    Add a nameserver selector and column to the instance page, a resource model 
page
    with the ownership topology, and a question-mark help modal on the create
    instance, topic and group forms that spells out the three creation outcomes.
    The creation-decision flow diagram is deliberately left out of the page: it 
read
    as a backend implementation detail, so the rule now lives where creation 
happens.
---
 web/src/App.tsx                                    |   2 +
 web/src/api/instance.ts                            |   3 +
 web/src/components/ResourceRuleHelp.tsx            | 120 +++++++
 .../components/__tests__/ResourceRuleHelp.test.tsx | 125 +++++++
 web/src/i18n/translations.ts                       | 133 ++++++++
 web/src/layouts/MainLayout.tsx                     |   7 +
 .../pages/instance/__tests__/ConsumerPage.test.tsx |  15 +
 .../pages/instance/__tests__/InstancePage.test.tsx |  40 ++-
 .../pages/instance/__tests__/TopicPage.test.tsx    |  12 +
 web/src/pages/instance/consumer.tsx                |   8 +-
 web/src/pages/instance/index.tsx                   |  79 +++++
 web/src/pages/instance/topic.tsx                   |  12 +-
 web/src/pages/studio/ResourceModel.tsx             | 370 +++++++++++++++++++++
 .../pages/studio/__tests__/ResourceModel.test.tsx  | 122 +++++++
 14 files changed, 1042 insertions(+), 6 deletions(-)

diff --git a/web/src/App.tsx b/web/src/App.tsx
index 4e7284fe3..aaaef366a 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -55,6 +55,7 @@ const ProducerPage = lazy(() => 
import('./pages/studio/Producer'));
 const OpsPage = lazy(() => import('./pages/studio/Ops'));
 const AlertRuleAssetsPage = lazy(() => 
import('./pages/studio/AlertRuleAssets'));
 const UserManagementPage = lazy(() => import('./pages/studio/UserManagement'));
+const ResourceModelPage = lazy(() => import('./pages/studio/ResourceModel'));
 
 type AuthGateState = 'checking' | 'allowed' | 'denied' | 'error';
 
@@ -236,6 +237,7 @@ function App() {
               element={<Navigate to="/ops/business-alerts" replace />}
             />
             <Route path="studio/users" element={<UserManagementPage />} />
+            <Route path="studio/resource-model" element={<ResourceModelPage 
/>} />
             <Route path="ops/alert-rule-templates" 
element={<AlertRuleAssetsPage />} />
             <Route path="*" element={<Navigate to="/" replace />} />
           </Route>
diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts
index 6d17aefef..90e0248c5 100644
--- a/web/src/api/instance.ts
+++ b/web/src/api/instance.ts
@@ -42,6 +42,8 @@ export interface Instance {
   adminCredentialRef?: string;
   regionId?: string;
   regionName?: string;
+  nameserverId?: number | null;
+  nameserverName?: string | null;
   topicCount: number;
   consumerGroupCount: number;
   resourceCountsAvailable?: boolean;
@@ -59,6 +61,7 @@ export interface CreateInstanceRequest {
   credentialId?: number;
   adminCredentialRef?: string;
   regionId?: string;
+  nameserverId?: number;
 }
 
 export interface UpdateInstanceRequest {
diff --git a/web/src/components/ResourceRuleHelp.tsx 
b/web/src/components/ResourceRuleHelp.tsx
new file mode 100644
index 000000000..85302aa43
--- /dev/null
+++ b/web/src/components/ResourceRuleHelp.tsx
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useState } from 'react';
+import { Button, Modal, Space, Tooltip, Typography, theme } from 'antd';
+import { QuestionCircleOutlined } from '@ant-design/icons';
+import { useLang } from '../i18n/LangContext';
+
+const { Text } = Typography;
+
+const FONT_SIZE = 14;
+
+/**
+ * 创建实例 / Topic / Group 表单上的规则入口:问号点开弹窗,讲清名字的唯一性边界与
+ * 创建时的三种结果。文案与「资源模型」页共用 `resourceModel.*` 一套口径。
+ */
+const ResourceRuleHelp = () => {
+  const { t } = useLang();
+  const { token } = theme.useToken();
+  const [open, setOpen] = useState(false);
+
+  const outcomes = [
+    {
+      color: token.colorSuccess,
+      title: t('resourceModel.outcomeFreeTitle'),
+      desc: t('resourceModel.outcomeFreeDesc'),
+    },
+    {
+      color: token.colorPrimary,
+      title: t('resourceModel.outcomeOwnTitle'),
+      desc: t('resourceModel.outcomeOwnDesc'),
+    },
+    {
+      color: token.colorError,
+      title: t('resourceModel.outcomeOtherTitle'),
+      desc: t('resourceModel.outcomeOtherDesc'),
+    },
+  ];
+
+  return (
+    <>
+      <Tooltip title={t('resourceModel.helpTooltip')}>
+        <span
+          role="button"
+          tabIndex={0}
+          aria-label={t('resourceModel.helpAria')}
+          onClick={(event) => {
+            // The icon sits inside a Form.Item label, whose default click 
behaviour focuses the
+            // field - on a Select that would pop the dropdown open behind the 
modal.
+            event.preventDefault();
+            setOpen(true);
+          }}
+          onKeyDown={(event) => {
+            if (event.key !== 'Enter' && event.key !== ' ') return;
+            event.preventDefault();
+            setOpen(true);
+          }}
+          style={{ display: 'inline-flex', cursor: 'pointer' }}
+        >
+          <QuestionCircleOutlined style={{ color: token.colorTextTertiary, 
fontSize: FONT_SIZE }} />
+        </span>
+      </Tooltip>
+
+      <Modal
+        title={t('resourceModel.modalTitle')}
+        open={open}
+        width={520}
+        onCancel={() => setOpen(false)}
+        footer={<Button onClick={() => 
setOpen(false)}>{t('common.close')}</Button>}
+      >
+        <Space direction="vertical" size={14} style={{ width: '100%', 
marginTop: 8 }}>
+          <Text style={{ fontSize: FONT_SIZE, lineHeight: 1.7 }}>
+            {t('resourceModel.modalIntro')}
+          </Text>
+          {outcomes.map((outcome) => (
+            <Space key={outcome.title} align="start" size={10}>
+              <span
+                aria-hidden
+                style={{
+                  width: 8,
+                  height: 8,
+                  marginTop: 7,
+                  borderRadius: '50%',
+                  background: outcome.color,
+                  flexShrink: 0,
+                }}
+              />
+              <div>
+                <Text strong style={{ fontSize: FONT_SIZE }}>
+                  {outcome.title}
+                </Text>
+                <div>
+                  <Text type="secondary" style={{ fontSize: FONT_SIZE, 
lineHeight: 1.7 }}>
+                    {outcome.desc}
+                  </Text>
+                </div>
+              </div>
+            </Space>
+          ))}
+        </Space>
+      </Modal>
+    </>
+  );
+};
+
+export default ResourceRuleHelp;
diff --git a/web/src/components/__tests__/ResourceRuleHelp.test.tsx 
b/web/src/components/__tests__/ResourceRuleHelp.test.tsx
new file mode 100644
index 000000000..afbf21736
--- /dev/null
+++ b/web/src/components/__tests__/ResourceRuleHelp.test.tsx
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import { LangProvider } from '../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../i18n/languagePreference';
+import translations from '../../i18n/translations';
+import ResourceRuleHelp from '../ResourceRuleHelp';
+
+beforeAll(() => {
+  Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    value: vi.fn().mockImplementation((query: string) => ({
+      matches: false,
+      media: query,
+      onchange: null,
+      addListener: vi.fn(),
+      removeListener: vi.fn(),
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      dispatchEvent: vi.fn(),
+    })),
+  });
+});
+
+const renderHelp = (lang: 'zh' | 'en') => {
+  localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
+  return render(
+    <App>
+      <LangProvider>
+        <ResourceRuleHelp />
+      </LangProvider>
+    </App>,
+  );
+};
+
+const OUTCOME_KEYS = [
+  'resourceModel.outcomeFreeTitle',
+  'resourceModel.outcomeOwnTitle',
+  'resourceModel.outcomeOtherTitle',
+] as const;
+
+describe('ResourceRuleHelp', () => {
+  beforeEach(() => {
+    localStorage.clear();
+  });
+
+  it('keeps the rule hidden until the question mark is used', async () => {
+    const user = userEvent.setup();
+    renderHelp('zh');
+    expect(screen.queryByText('创建时的归属规则')).toBeNull();
+
+    await user.click(screen.getByRole('button', { name: '查看资源命名与归属规则' }));
+    expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+  });
+
+  it('opens from the keyboard so the form stays reachable without a mouse', 
async () => {
+    const user = userEvent.setup();
+    renderHelp('zh');
+    const trigger = screen.getByRole('button', { name: '查看资源命名与归属规则' });
+    trigger.focus();
+    await user.keyboard('{Enter}');
+    expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+  });
+
+  it('spells out the three creation outcomes', async () => {
+    const user = userEvent.setup();
+    renderHelp('zh');
+    await user.click(screen.getByRole('button', { name: '查看资源命名与归属规则' }));
+
+    OUTCOME_KEYS.forEach((key) => {
+      expect(screen.getByText(translations[key].zh)).toBeInTheDocument();
+    });
+    
expect(screen.getByText(translations['resourceModel.outcomeOtherDesc'].zh)).toBeInTheDocument();
+  });
+
+  it.each(['zh', 'en'] as const)(
+    'resolves every %s label without leaking i18n keys',
+    async (lang) => {
+      const user = userEvent.setup();
+      const { baseElement } = renderHelp(lang);
+      await user.click(
+        screen.getByRole('button', { name: 
translations['resourceModel.helpAria'][lang] }),
+      );
+
+      expect(
+        await 
screen.findByText(translations['resourceModel.modalTitle'][lang]),
+      ).toBeInTheDocument();
+      expect(baseElement.textContent).not.toContain('resourceModel.');
+    },
+  );
+
+  it('hands the modal back to antd to close from the footer button', async () 
=> {
+    const user = userEvent.setup();
+    renderHelp('zh');
+    await user.click(screen.getByRole('button', { name: '查看资源命名与归属规则' }));
+    expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+
+    // antd pads a two-character CJK button label with a space, so match it 
loosely.
+    await user.click(screen.getByRole('button', { name: /关\s*闭/ }));
+    // jsdom never fires animationend, so a closed antd modal stays mounted 
mid-leave instead of
+    // leaving the accessibility tree. The leave class on the mask is the 
observable proof that
+    // `open` flipped back to false.
+    await waitFor(() =>
+      
expect(document.querySelector('.ant-modal-mask')?.className).toContain('ant-fade-leave'),
+    );
+  });
+});
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index de81f7b05..dc4d861b4 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -40,6 +40,7 @@ const translations: Record<string, Record<Lang, string>> = {
   'nav.grafanaDashboards': { zh: 'Grafana 看板', en: 'Grafana Dashboards' },
   'nav.ai': { zh: 'AI 交互', en: 'AI Chat' },
   'nav.settings': { zh: '设置', en: 'Settings' },
+  'nav.resourceModel': { zh: '资源模型', en: 'Resource Model' },
 
   // ─── Common ───
   'common.search': { zh: '搜索...', en: 'Search...' },
@@ -463,6 +464,23 @@ const translations: Record<string, Record<Lang, string>> = 
{
     en: 'No {vendor} credentials. ',
   },
   'instance.addInSettings': { zh: '去设置中添加', en: 'Add in settings' },
+  'instance.nameserver': { zh: 'nameserver 集群', en: 'NameServer cluster' },
+  'instance.nameserverRequired': {
+    zh: '请选择 nameserver 集群',
+    en: 'Select a NameServer cluster',
+  },
+  'instance.nameserverExtra': {
+    zh: '实例的名字唯一性以该集群为边界,创建后不可更改',
+    en: 'Name uniqueness is scoped to this cluster and the binding cannot 
change later',
+  },
+  'instance.nameserverImmutable': {
+    zh: '创建时绑定,不可更改',
+    en: 'Bound at creation, cannot be changed',
+  },
+  'instance.selectNameserver': { zh: '选择 nameserver 集群', en: 'Select a 
NameServer cluster' },
+  'instance.noNameserver': { zh: '还没有注册 nameserver,', en: 'No NameServer 
registered yet, ' },
+  'instance.addInClusterPage': { zh: '去集群管理注册', en: 'register one in Cluster 
Management' },
+  'instance.nameserverColumn': { zh: 'nameserver 集群', en: 'NameServer cluster' 
},
   'instance.regionRequired': { zh: '请选择地域', en: 'Select a region' },
   'instance.selectRegion': { zh: '选择地域', en: 'Select a region' },
   'instance.cloudInstance': { zh: '云上实例', en: 'Cloud Instance' },
@@ -3980,6 +3998,121 @@ const translations: Record<string, Record<Lang, 
string>> = {
     zh: 'Claude / Titan / Llama (AWS)',
     en: 'Claude / Titan / Llama (AWS)',
   },
+  // ─── ResourceModel(资源命名与归属:说明页 + 创建表单的规则弹窗)───
+  'resourceModel.title': { zh: '资源模型', en: 'Resource Model' },
+  'resourceModel.banner': {
+    zh:
+      'Studio 把每一组 nameserver 看作一套独立的环境,例如生产与预发各用一组。Topic 与 Group 的名字' +
+      '在同一套环境里的同一个 broker 集群上唯一;换一套环境、或换同一套环境里的另一个 broker 集群,' +
+      '都可以继续使用相同的名字。',
+    en:
+      'Studio treats each set of NameServers as one independent environment, 
for example one for production and ' +
+      'one for staging. A topic or group name is unique on one broker cluster 
within one environment; another ' +
+      'environment, or another broker cluster in the same environment, may use 
the same name again.',
+  },
+
+  'resourceModel.archTitle': { zh: '命名与归属', en: 'Naming and ownership' },
+  'resourceModel.svgTopologyAria': {
+    zh: '生产与预发两套 nameserver 环境下 Topic 名字的归属示意',
+    en: 'Topic ownership across the production and staging NameServer 
environments',
+  },
+  'resourceModel.svgDomainProd': {
+    zh: 'nameserver 集群「生产」',
+    en: 'NameServer cluster "production"',
+  },
+  'resourceModel.svgDomainStaging': {
+    zh: 'nameserver 集群「预发」',
+    en: 'NameServer cluster "staging"',
+  },
+  'resourceModel.svgChipOrdersTrade': { zh: 'orders · 交易', en: 'orders · 
trade' },
+  'resourceModel.svgChipPaymentsTrade': { zh: 'payments · 交易', en: 'payments · 
trade' },
+  'resourceModel.svgChipOrdersLogistics': { zh: 'orders · 物流', en: 'orders · 
logistics' },
+  'resourceModel.svgChipOrdersStaging': { zh: 'orders · 预发', en: 'orders · 
staging' },
+  'resourceModel.svgMergeNote1': {
+    zh: '同一个名字用在生产的两个 broker 集群上时,路由会合并:',
+    en: 'One name on both production clusters merges routes:',
+  },
+  'resourceModel.svgMergeNote2': {
+    zh: '客户端的消息会在两个 broker 集群之间负载均衡',
+    en: 'clients load-balance messages across both clusters',
+  },
+  'resourceModel.svgInstTrade': { zh: '实例「交易」', en: 'Instance "trade"' },
+  'resourceModel.svgInstLogistics': { zh: '实例「物流」', en: 'Instance "logistics"' 
},
+  'resourceModel.svgInstStaging': { zh: '实例「预发」', en: 'Instance "staging"' },
+  'resourceModel.svgBoundNote': { zh: '创建时绑定,不可更改', en: 'fixed at creation' },
+  'resourceModel.svgConflictLine1': {
+    zh: '实例「物流」在 broker-a 上创建 orders',
+    en: 'Instance "logistics" creates orders on broker-a',
+  },
+  'resourceModel.svgConflictLine2': {
+    zh: 'Studio 会提示这个名字已经属于实例「交易」',
+    en: 'Studio reports the name belongs to Instance "trade"',
+  },
+  'resourceModel.svgCrossDomainNote': {
+    zh: '另一套环境,名字可以重复',
+    en: 'Names may repeat here',
+  },
+  'resourceModel.svgCrossDomainNote2': {
+    zh: '路由各自独立,不会合并',
+    en: 'routes stay separate, never merged',
+  },
+  'resourceModel.svgCrossLine1': {
+    zh: '预发的 orders 与生产的 orders 同名',
+    en: 'staging orders shares the name',
+  },
+  'resourceModel.svgCrossLine2': { zh: '两者各自独立,互不影响', en: 'The two stay 
independent' },
+
+  'resourceModel.rulesTitle': { zh: '归属规则', en: 'Ownership rules' },
+  'resourceModel.rule1': {
+    zh: '· 一个名字在同一个 broker 集群上只属于一个实例。如果它已经属于别的实例,创建时会告诉你它属于谁。',
+    en: '· A name belongs to one instance per broker cluster. If another 
instance already uses it, the create form tells you which one.',
+  },
+  'resourceModel.rule2': {
+    zh: '· 同一个实例再次创建同名资源,相当于按这次填写的队列数等参数更新它。',
+    en: '· Creating the same name again from the owning instance updates it 
with the queue counts and other settings you submit.',
+  },
+  'resourceModel.rule3': {
+    zh: '· 实例在创建时选定 nameserver 集群,之后不能更改;直连模式的接入地址必须始终指向该集群。',
+    en: '· An instance picks its NameServer cluster at creation and keeps it 
forever; a direct endpoint must always address that same cluster.',
+  },
+  'resourceModel.rule4': {
+    zh: '· 云实例(阿里云、腾讯云)各自是一套独立的环境,与开源实例之间不会出现名字冲突。',
+    en: '· Cloud instances (Alibaba Cloud, Tencent Cloud) are each an 
environment of their own and never collide with open-source instances.',
+  },
+
+  'resourceModel.helpTooltip': { zh: '资源命名与归属规则', en: 'Naming and ownership 
rules' },
+  'resourceModel.helpAria': {
+    zh: '查看资源命名与归属规则',
+    en: 'Show the naming and ownership rules',
+  },
+  'resourceModel.modalTitle': { zh: '创建时的归属规则', en: 'Ownership rules when you 
create' },
+  'resourceModel.modalIntro': {
+    zh:
+      '一个名字在「nameserver 集群 + broker 集群」这个范围内唯一。实例在创建时选定 nameserver ' +
+      '集群,之后不能更改;云实例各自是一套独立的环境。',
+    en:
+      'A name is unique within one NameServer cluster plus one broker cluster. 
An instance picks its ' +
+      'NameServer cluster at creation and keeps it forever; each cloud 
instance is an environment of its own.',
+  },
+  'resourceModel.outcomeFreeTitle': { zh: '这个名字还没有人使用', en: 'The name is not 
used yet' },
+  'resourceModel.outcomeFreeDesc': { zh: '正常创建。', en: 'It is created.' },
+  'resourceModel.outcomeOwnTitle': {
+    zh: '这个名字已经属于当前实例',
+    en: 'The name already belongs to this instance',
+  },
+  'resourceModel.outcomeOwnDesc': {
+    zh: '相当于按这次填写的队列数等参数更新它。',
+    en: 'It is updated with the queue counts and other settings you submit.',
+  },
+  'resourceModel.outcomeOtherTitle': {
+    zh: '这个名字已经属于其他实例',
+    en: 'The name already belongs to another instance',
+  },
+  'resourceModel.outcomeOtherDesc': {
+    zh: 'Studio 会告诉你它属于哪个实例,并停止本次创建。',
+    en: 'Studio tells you which instance owns it and stops this creation.',
+  },
+
   // ─── BrokerCluster ───
 };
 
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index 5a2344998..7d26943c1 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -40,6 +40,7 @@ import {
   PaperPlaneTilt,
   Notebook,
   Warning,
+  TreeStructure,
 } from '@phosphor-icons/react';
 import { useLang } from '../i18n/LangContext';
 import { useTheme } from '../theme/useTheme';
@@ -233,6 +234,11 @@ const MainLayout = () => {
             label: t('nav.alertDeliveries'),
           },
           { key: '/ops/dashboard', icon: <ChartBar size={16} />, label: 
t('nav.dashboard') },
+          {
+            key: '/studio/resource-model',
+            icon: <TreeStructure size={16} />,
+            label: t('nav.resourceModel'),
+          },
         ],
       },
       {
@@ -274,6 +280,7 @@ const MainLayout = () => {
       '/ai': t('nav.ai'),
       '/settings': t('nav.settings'),
       '/studio/users': t('userMgmt.title'),
+      '/studio/resource-model': t('nav.resourceModel'),
     }),
     [t],
   );
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx 
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index b7e49403d..c48281ad0 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -543,6 +543,21 @@ describe('Consumer page', () => {
     confirmSpy.mockRestore();
   });
 
+  // 创建规则从「资源模型」页的判定流程图搬到了创建表单上的问号弹窗(用户约定 2026-10-26)。
+  it('explains the naming rule from a question mark on the create form', async 
() => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderWithProviders(<ConsumerPage />);
+
+    await screen.findByText('remote-cg');
+    const createButton = screen.getByRole('button', { name: '创建 Group' });
+    await waitFor(() => expect(createButton).toBeEnabled());
+    await user.click(createButton);
+    const dialog = await screen.findByRole('dialog');
+
+    await user.click(within(dialog).getByRole('button', { name: '查看资源命名与归属规则' 
}));
+    expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+  });
+
   it('submits the canonical global delivery order type', async () => {
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) 
=> {
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx 
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 2f07260e4..22ae808e3 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -21,6 +21,7 @@ import userEvent from '@testing-library/user-event';
 import { MemoryRouter } from 'react-router-dom';
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
 import * as aliyunCatalogApi from '../../../api/aliyunCatalog';
+import * as clusterApi from '../../../api/cluster';
 import * as cloudCredentialApi from '../../../api/cloudCredential';
 import * as tencentCatalogApi from '../../../api/tencentCatalog';
 import type { CloudCredential, CloudCredentialPage } from 
'../../../api/cloudCredential';
@@ -39,6 +40,9 @@ vi.mock('../../../api/cloudCredential', () => ({
   listCloudCredentials: vi.fn(),
 }));
 
+vi.mock('../../../api/cluster', () => ({
+  listNameserverRegistry: vi.fn(),
+}));
 vi.mock('../../../api/tencentCatalog', () => ({
   listTencentInstances: vi.fn(),
   listTencentRegions: vi.fn(),
@@ -123,6 +127,19 @@ describe('InstancePage', () => {
     vi.mocked(aliyunCatalogApi.listAliyunInstances).mockResolvedValue([]);
     vi.mocked(tencentCatalogApi.listTencentRegions).mockResolvedValue([]);
     vi.mocked(tencentCatalogApi.listTencentInstances).mockResolvedValue([]);
+    vi.mocked(clusterApi.listNameserverRegistry).mockResolvedValue([
+      {
+        id: 7,
+        name: 'ns-test',
+        namesrvAddr: 'ns-test:9876',
+        k8sNamespace: null,
+        k8sId: null,
+        status: 'healthy',
+        description: null,
+        gmtCreate: null,
+        gmtModified: null,
+      },
+    ]);
     vi.mocked(instanceService.listInstances).mockResolvedValue([
       instance(1, 'production-proxy'),
       instance(2, 'development-direct', 'DIRECT'),
@@ -351,6 +368,19 @@ describe('InstancePage', () => {
     expect(await screen.findByText('云凭据列表加载失败')).toBeInTheDocument();
     expect(cloudCredentialApi.listCloudCredentials).toHaveBeenCalledTimes(1);
   });
+
+  // 创建规则从「资源模型」页的判定流程图搬到了创建表单上的问号弹窗(用户约定 2026-10-26)。
+  it('offersTheNamingRuleFromTheCreateInstanceFormTest', async () => {
+    const user = userEvent.setup();
+    renderPage();
+    await screen.findByText('production-proxy');
+    await user.click(screen.getByRole('button', { name: /添加实例/ }));
+    const dialog = await screen.findByRole('dialog');
+
+    await user.click(within(dialog).getByRole('button', { name: '查看资源命名与归属规则' 
}));
+    expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+  });
+
   it('loads server-filtered results when the search or type changes', async () 
=> {
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();
@@ -496,7 +526,7 @@ describe('InstancePage', () => {
     await user.click(screen.getByRole('button', { name: /添加实例/ }));
     const dialog = await screen.findByRole('dialog');
     await user.type(within(dialog).getByLabelText('实例 ID'), 'new-proxy');
-    const createTypeSelect = within(dialog).getByRole('combobox');
+    const createTypeSelect = within(dialog).getAllByRole('combobox')[1];
     fireEvent.mouseDown(createTypeSelect.parentElement!);
     const proxyOptions = await screen.findAllByText('Proxy Cluster 模式', {
       selector: '.ant-select-item-option-content',
@@ -532,7 +562,7 @@ describe('InstancePage', () => {
     await user.click(screen.getByRole('button', { name: /添加实例/ }));
     const dialog = await screen.findByRole('dialog');
     await user.type(within(dialog).getByLabelText('实例 ID'), 'new-proxy');
-    const createTypeSelect = within(dialog).getByRole('combobox');
+    const createTypeSelect = within(dialog).getAllByRole('combobox')[1];
     fireEvent.mouseDown(createTypeSelect.parentElement!);
     const proxyOptions = await screen.findAllByText('Proxy Cluster 模式', {
       selector: '.ant-select-item-option-content',
@@ -546,6 +576,7 @@ describe('InstancePage', () => {
         name: 'new-proxy',
         type: 'PROXY_CLUSTER',
         endpoint: 'proxy-new:8080',
+        nameserverId: 7,
       }),
     );
     expect(instanceService.listInstances).toHaveBeenLastCalledWith({
@@ -565,7 +596,7 @@ describe('InstancePage', () => {
     await user.click(screen.getByRole('button', { name: /添加实例/ }));
     const dialog = await screen.findByRole('dialog');
     await user.type(within(dialog).getByLabelText('实例 ID'), 'local-proxy');
-    const createTypeSelect = within(dialog).getByRole('combobox');
+    const createTypeSelect = within(dialog).getAllByRole('combobox')[1];
     fireEvent.mouseDown(createTypeSelect.parentElement!);
     await user.click(
       await screen.findByText('Proxy Local 模式', {
@@ -580,6 +611,7 @@ describe('InstancePage', () => {
         name: 'local-proxy',
         type: 'PROXY_LOCAL',
         endpoint: 'broker-proxy:8080',
+        nameserverId: 7,
       }),
     );
   });
@@ -697,7 +729,7 @@ describe('InstancePage', () => {
     await user.click(screen.getByRole('button', { name: /添加实例/ }));
     const dialog = await screen.findByRole('dialog');
     await user.type(within(dialog).getByLabelText('实例 ID'), 'new-proxy');
-    const createTypeSelect = within(dialog).getByRole('combobox');
+    const createTypeSelect = within(dialog).getAllByRole('combobox')[1];
     fireEvent.mouseDown(createTypeSelect.parentElement!);
     const proxyOptions = await screen.findAllByText('Proxy Cluster 模式', {
       selector: '.ant-select-item-option-content',
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx 
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index e3344974e..512d85264 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -300,6 +300,18 @@ describe('TopicPage', () => {
     expect(within(dialog).getAllByText(/每个 Broker 节点写/).length).toBe(1);
   });
 
+  // 创建规则从「资源模型」页的判定流程图搬到了创建表单上的问号弹窗(用户约定 2026-10-26)。
+  it('explains the naming rule from a question mark on the create form', async 
() => {
+    renderWithProviders('/instance/instance-proxy-1/topic');
+    await screen.findByText('topic-01');
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+
+    await user.click(screen.getByRole('button', { name: /创建 Topic/ }));
+    const dialog = await screen.findByRole('dialog', { name: /创建 Topic/ });
+    await user.click(within(dialog).getByRole('button', { name: '查看资源命名与归属规则' 
}));
+    expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+  });
+
   it('gives each of the four row actions its own color', async () => {
     renderWithProviders('/instance/instance-proxy-1/topic');
     await screen.findByText('topic-01');
diff --git a/web/src/pages/instance/consumer.tsx 
b/web/src/pages/instance/consumer.tsx
index 1864c020c..febc23a3a 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -67,6 +67,7 @@ import type { Dayjs } from 'dayjs';
 
 import PageHeader from '../../components/PageHeader';
 import { InstanceSelect } from '../../components/InstanceSelect';
+import ResourceRuleHelp from '../../components/ResourceRuleHelp';
 import { useLang } from '../../i18n/LangContext';
 import { TOPIC_TYPE_MAP, PROTOCOL_MAP } from '../../constants/theme';
 import { formatDateTime, formatDelay, formatUtcDateTime } from 
'../../utils/format';
@@ -2469,7 +2470,12 @@ const ConsumerPageContent = ({
           }}
         >
           <Form.Item
-            label="Group 名称"
+            label={
+              <Space size={4}>
+                Group 名称
+                <ResourceRuleHelp />
+              </Space>
+            }
             name="name"
             rules={[
               { required: true, message: '请输入 Group 名称' },
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index 85d997688..66f830b24 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -40,6 +40,7 @@ import type { ColumnsType } from 'antd/es/table';
 import type { SortOrder } from 'antd/es/table/interface';
 import type { Instance, InstanceQuery } from '../../api/instance';
 import { listCloudCredentials, type CloudCredential } from 
'../../api/cloudCredential';
+import { listNameserverRegistry, type NameserverRegistryEntry } from 
'../../api/cluster';
 import {
   listAliyunInstances,
   listAliyunRegions,
@@ -50,6 +51,7 @@ import { listTencentInstances, listTencentRegions } from 
'../../api/tencentCatal
 import { describeApiError } from '../../utils/apiError';
 import { formatDateTime } from '../../utils/format';
 import { tableScrollX } from '../../utils/table';
+import ResourceRuleHelp from '../../components/ResourceRuleHelp';
 import {
   createInstance,
   deleteInstance,
@@ -140,6 +142,8 @@ const InstancePage = () => {
   const [cloudInstances, setCloudInstances] = 
useState<CloudInstanceOption[]>([]);
   const [cloudInstancesLoading, setCloudInstancesLoading] = useState(false);
   const [editModalOpen, setEditModalOpen] = useState(false);
+  const [nameservers, setNameservers] = 
useState<NameserverRegistryEntry[]>([]);
+  const [nameserversLoading, setNameserversLoading] = useState(false);
   const [editingInstance, setEditingInstance] = useState<Instance | 
null>(null);
   const [editForm] = Form.useForm();
   const editInstanceType = Form.useWatch<Instance['type'] | undefined>('type', 
editForm);
@@ -193,6 +197,38 @@ const InstancePage = () => {
 
   const cloudVendor = vendor === 'ALIYUN' || vendor === 'TENCENT';
 
+  useEffect(() => {
+    if (nameservers.length === 1 && addForm.getFieldValue('nameserverId') == 
null) {
+      addForm.setFieldsValue({ nameserverId: nameservers[0].id });
+    }
+  }, [nameservers, addForm]);
+
+  useEffect(() => {
+    if (!(addModalOpen || editModalOpen) || cloudVendor) {
+      return;
+    }
+    let active = true;
+    // Deferred like the credential loader: a synchronous setState here trips 
the
+    // react-hooks lint rule about cascading renders inside effect bodies.
+    const timer = window.setTimeout(() => {
+      setNameserversLoading(true);
+      listNameserverRegistry()
+        .then((entries) => {
+          if (active) setNameservers(entries);
+        })
+        .catch(() => {
+          if (active) setNameservers([]);
+        })
+        .finally(() => {
+          if (active) setNameserversLoading(false);
+        });
+    }, 0);
+    return () => {
+      active = false;
+      window.clearTimeout(timer);
+    };
+  }, [addModalOpen, editModalOpen, cloudVendor]);
+
   useEffect(() => {
     if (!cloudVendor || !addModalOpen) {
       const timer = window.setTimeout(() => setCredentialsLoading(false), 0);
@@ -619,6 +655,14 @@ const InstancePage = () => {
         return <Tag color={config.color}>{typeLabel[type] ? t(config.labelKey) 
: type}</Tag>;
       },
     },
+    {
+      title: t('instance.nameserverColumn'),
+      dataIndex: 'nameserverName',
+      key: 'nameserverName',
+      width: 140,
+      render: (name: string | null, record: Instance) =>
+        record.vendor === 'ALIYUN' || record.vendor === 'TENCENT' ? '-' : 
(name ?? '-'),
+    },
     {
       title: 'Topic',
       dataIndex: 'topicCount',
@@ -964,6 +1008,36 @@ const InstancePage = () => {
             >
               <Input placeholder={t('instance.namePlaceholder')} />
             </Form.Item>
+            <Form.Item
+              label={
+                <Space size={4}>
+                  {t('instance.nameserver')}
+                  <ResourceRuleHelp />
+                </Space>
+              }
+              name="nameserverId"
+              rules={[{ required: true, message: 
t('instance.nameserverRequired') }]}
+              extra={t('instance.nameserverExtra')}
+            >
+              <Select
+                placeholder={t('instance.selectNameserver')}
+                loading={nameserversLoading}
+                notFoundContent={
+                  nameserversLoading ? (
+                    t('instance.loading')
+                  ) : (
+                    <span>
+                      {t('instance.noNameserver')}
+                      <Link 
to="/cluster">{t('instance.addInClusterPage')}</Link>
+                    </span>
+                  )
+                }
+                options={nameservers.map((entry) => ({
+                  value: entry.id,
+                  label: `${entry.name}(${entry.namesrvAddr})`,
+                }))}
+              />
+            </Form.Item>
             <Form.Item
               label={t('instance.accessType')}
               name="type"
@@ -1027,6 +1101,11 @@ const InstancePage = () => {
           <Form.Item label={t('instance.instanceName')}>
             <Input value={editingInstance?.name} disabled />
           </Form.Item>
+          {editingInstance?.vendor !== 'ALIYUN' && editingInstance?.vendor !== 
'TENCENT' && (
+            <Form.Item label={t('instance.nameserver')} 
extra={t('instance.nameserverImmutable')}>
+              <Input value={editingInstance?.nameserverName ?? '-'} disabled />
+            </Form.Item>
+          )}
           <Form.Item
             label={t('instance.accessType')}
             name="type"
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 76b6dd88e..ad75998a3 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -59,6 +59,7 @@ import PageHeader from '../../components/PageHeader';
 import InfoBanner from '../../components/InfoBanner';
 import { InstanceSelect } from '../../components/InstanceSelect';
 import TopicConfigComparisonModal from 
'../../components/TopicConfigComparisonModal';
+import ResourceRuleHelp from '../../components/ResourceRuleHelp';
 import { useLang } from '../../i18n/LangContext';
 import { TOPIC_TYPE_MAP, CLUSTER_TYPE_MAP } from '../../constants/theme';
 import type { Topic, BrokerRoute, ConsumerGroupInfo, TopicConsumerPage } from 
'../../api/metadata';
@@ -1526,7 +1527,16 @@ const TopicPageContent = ({
           style={{ marginTop: 16 }}
         >
           <Form.Item
-            label="Topic 名称"
+            label={
+              editingTopic ? (
+                'Topic 名称'
+              ) : (
+                <Space size={4}>
+                  Topic 名称
+                  <ResourceRuleHelp />
+                </Space>
+              )
+            }
             name="name"
             rules={[
               { required: true, message: '请输入 Topic 名称' },
diff --git a/web/src/pages/studio/ResourceModel.tsx 
b/web/src/pages/studio/ResourceModel.tsx
new file mode 100644
index 000000000..963dc5791
--- /dev/null
+++ b/web/src/pages/studio/ResourceModel.tsx
@@ -0,0 +1,370 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { Card, Space, theme, Typography } from 'antd';
+import { TreeStructure } from '@phosphor-icons/react';
+import { useLang } from '../../i18n/LangContext';
+import { useTheme } from '../../theme/useTheme';
+import InfoBanner from '../../components/InfoBanner';
+
+const { Text, Title } = Typography;
+
+const FONT_SIZE = 14;
+
+/**
+ * 页面专用色板(用户指定 2026-10-23)。颜色只用于描边、浅色填充、圆点与标题下划线,
+ * 正文文字仍走 antd token 保证对比度;深色模式换一组提亮变体。
+ */
+const PALETTE = {
+  light: {
+    red: '#D4042D',
+    blue: '#5A92E5',
+    olive: '#5E8034',
+    teal: '#41B5C2',
+    purple: '#722169',
+  },
+  dark: {
+    red: '#FF7A8C',
+    blue: '#8FB9F2',
+    olive: '#A9C47E',
+    teal: '#7FD4DE',
+    purple: '#C993C0',
+  },
+};
+
+type PaletteKey = keyof typeof PALETTE.light;
+
+interface SvgBoxProps {
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  stroke: string;
+  fill?: string;
+  radius?: number;
+}
+
+const SvgBox: React.FC<SvgBoxProps> = ({ x, y, width, height, stroke, fill, 
radius = 10 }) => (
+  <rect
+    x={x}
+    y={y}
+    width={width}
+    height={height}
+    rx={radius}
+    fill={fill ?? 'transparent'}
+    stroke={stroke}
+    strokeWidth={1}
+  />
+);
+
+interface SvgLabelProps {
+  x: number;
+  y: number;
+  fill: string;
+  children: React.ReactNode;
+  bold?: boolean;
+  muted?: boolean;
+  anchor?: 'start' | 'middle';
+}
+
+const SvgLabel: React.FC<SvgLabelProps> = ({
+  x,
+  y,
+  fill,
+  children,
+  bold,
+  muted,
+  anchor = 'start',
+}) => (
+  <text
+    x={x}
+    y={y}
+    fill={fill}
+    fontSize={FONT_SIZE}
+    fontWeight={bold ? 600 : 400}
+    fontStyle={muted ? 'italic' : undefined}
+    textAnchor={anchor}
+  >
+    {children}
+  </text>
+);
+
+const SvgDot: React.FC<{ cx: number; cy: number; fill: string }> = ({ cx, cy, 
fill }) => (
+  <circle cx={cx} cy={cy} r={3.5} fill={fill} />
+);
+
+/**
+ * 面向用户的资源命名与归属说明:以 nameserver 集群为唯一性边界,
+ * 同一个名字在同一个 broker 集群上只属于一个实例。
+ */
+const ResourceModelPage: React.FC = () => {
+  const { t } = useLang();
+  const { darkMode } = useTheme();
+  const { token } = theme.useToken();
+  const c = darkMode ? PALETTE.dark : PALETTE.light;
+  const tint = (key: PaletteKey, alpha: string) => `${c[key]}${alpha}`;
+
+  return (
+    <div style={{ padding: '0 0 24px' }}>
+      <Card
+        size="small"
+        title={
+          <Space>
+            <TreeStructure size={18} />
+            <span>{t('resourceModel.title')}</span>
+          </Space>
+        }
+      >
+        <InfoBanner description={t('resourceModel.banner')} 
data-testid="resource-model-banner" />
+
+        <Title level={5}>{t('resourceModel.archTitle')}</Title>
+        <svg
+          role="img"
+          aria-label={t('resourceModel.svgTopologyAria')}
+          viewBox="0 0 760 332"
+          style={{
+            width: '100%',
+            maxWidth: 760,
+            height: 'auto',
+            display: 'block',
+            margin: '0 auto',
+          }}
+          data-testid="resource-model-topology"
+        >
+          <SvgBox
+            x={8}
+            y={8}
+            width={430}
+            height={316}
+            radius={14}
+            stroke={token.colorBorderSecondary}
+            fill={token.colorFillQuaternary}
+          />
+          <SvgLabel x={24} y={34} fill={token.colorText} bold>
+            {t('resourceModel.svgDomainProd')}
+          </SvgLabel>
+          <rect x={24} y={42} width={48} height={3} rx={1.5} fill={c.blue} />
+          <SvgLabel x={24} y={62} fill={token.colorTextSecondary} muted>
+            10.0.0.1:9876
+          </SvgLabel>
+
+          <SvgBox
+            x={24}
+            y={74}
+            width={193}
+            height={96}
+            stroke={tint('blue', '59')}
+            fill={token.colorBgContainer}
+          />
+          <SvgDot cx={40} cy={96} fill={c.blue} />
+          <SvgLabel x={50} y={101} fill={token.colorText} bold>
+            broker-a
+          </SvgLabel>
+          <SvgBox
+            x={36}
+            y={110}
+            width={169}
+            height={26}
+            radius={6}
+            stroke={tint('olive', '73')}
+            fill={tint('olive', '1F')}
+          />
+          <SvgLabel x={46} y={128} fill={token.colorText}>
+            {t('resourceModel.svgChipOrdersTrade')}
+          </SvgLabel>
+          <SvgBox
+            x={36}
+            y={142}
+            width={169}
+            height={26}
+            radius={6}
+            stroke={tint('olive', '73')}
+            fill={tint('olive', '1F')}
+          />
+          <SvgLabel x={46} y={160} fill={token.colorText}>
+            {t('resourceModel.svgChipPaymentsTrade')}
+          </SvgLabel>
+
+          <SvgBox
+            x={229}
+            y={74}
+            width={193}
+            height={96}
+            stroke={tint('blue', '59')}
+            fill={token.colorBgContainer}
+          />
+          <SvgDot cx={245} cy={96} fill={c.blue} />
+          <SvgLabel x={255} y={101} fill={token.colorText} bold>
+            broker-b
+          </SvgLabel>
+          <SvgBox
+            x={241}
+            y={110}
+            width={169}
+            height={26}
+            radius={6}
+            stroke={tint('olive', '73')}
+            fill={tint('olive', '1F')}
+          />
+          <SvgLabel x={251} y={128} fill={token.colorText}>
+            {t('resourceModel.svgChipOrdersLogistics')}
+          </SvgLabel>
+
+          <SvgLabel x={24} y={190} fill={token.colorTextSecondary} muted>
+            {t('resourceModel.svgMergeNote1')}
+          </SvgLabel>
+          <SvgLabel x={24} y={208} fill={token.colorTextSecondary} muted>
+            {t('resourceModel.svgMergeNote2')}
+          </SvgLabel>
+
+          <SvgBox
+            x={24}
+            y={220}
+            width={193}
+            height={44}
+            stroke={token.colorBorder}
+            fill={token.colorBgContainer}
+          />
+          <SvgDot cx={40} cy={236} fill={c.teal} />
+          <SvgLabel x={50} y={241} fill={token.colorText} bold>
+            {t('resourceModel.svgInstTrade')}
+          </SvgLabel>
+          <SvgLabel x={50} y={257} fill={token.colorTextSecondary} muted>
+            {t('resourceModel.svgBoundNote')}
+          </SvgLabel>
+
+          <SvgBox
+            x={229}
+            y={220}
+            width={193}
+            height={44}
+            stroke={token.colorBorder}
+            fill={token.colorBgContainer}
+          />
+          <SvgDot cx={245} cy={236} fill={c.teal} />
+          <SvgLabel x={255} y={241} fill={token.colorText} bold>
+            {t('resourceModel.svgInstLogistics')}
+          </SvgLabel>
+          <SvgLabel x={255} y={257} fill={token.colorTextSecondary} muted>
+            {t('resourceModel.svgBoundNote')}
+          </SvgLabel>
+
+          <SvgBox
+            x={24}
+            y={276}
+            width={398}
+            height={40}
+            stroke={tint('red', '66')}
+            fill={tint('red', '14')}
+          />
+          <SvgLabel x={38} y={293} fill={c.red} bold>
+            {t('resourceModel.svgConflictLine1')}
+          </SvgLabel>
+          <SvgLabel x={38} y={309} fill={token.colorText}>
+            {t('resourceModel.svgConflictLine2')}
+          </SvgLabel>
+
+          <SvgBox
+            x={454}
+            y={8}
+            width={298}
+            height={316}
+            radius={14}
+            stroke={token.colorBorderSecondary}
+            fill={token.colorFillQuaternary}
+          />
+          <SvgLabel x={470} y={34} fill={token.colorText} bold>
+            {t('resourceModel.svgDomainStaging')}
+          </SvgLabel>
+          <rect x={470} y={42} width={48} height={3} rx={1.5} fill={c.purple} 
/>
+          <SvgLabel x={470} y={62} fill={token.colorTextSecondary} muted>
+            10.9.0.1:9876
+          </SvgLabel>
+
+          <SvgBox
+            x={470}
+            y={74}
+            width={266}
+            height={96}
+            stroke={tint('purple', '59')}
+            fill={token.colorBgContainer}
+          />
+          <SvgDot cx={486} cy={96} fill={c.purple} />
+          <SvgLabel x={496} y={101} fill={token.colorText} bold>
+            broker-s
+          </SvgLabel>
+          <SvgBox
+            x={482}
+            y={110}
+            width={242}
+            height={26}
+            radius={6}
+            stroke={tint('olive', '73')}
+            fill={tint('olive', '1F')}
+          />
+          <SvgLabel x={492} y={128} fill={token.colorText}>
+            {t('resourceModel.svgChipOrdersStaging')}
+          </SvgLabel>
+
+          <SvgLabel x={470} y={190} fill={token.colorTextSecondary} muted>
+            {t('resourceModel.svgCrossDomainNote')}
+          </SvgLabel>
+          <SvgLabel x={470} y={208} fill={token.colorTextSecondary} muted>
+            {t('resourceModel.svgCrossDomainNote2')}
+          </SvgLabel>
+
+          <SvgBox
+            x={470}
+            y={220}
+            width={266}
+            height={44}
+            stroke={token.colorBorder}
+            fill={token.colorBgContainer}
+          />
+          <SvgDot cx={486} cy={236} fill={c.teal} />
+          <SvgLabel x={496} y={241} fill={token.colorText} bold>
+            {t('resourceModel.svgInstStaging')}
+          </SvgLabel>
+          <SvgLabel x={496} y={257} fill={token.colorTextSecondary} muted>
+            {t('resourceModel.svgBoundNote')}
+          </SvgLabel>
+
+          <SvgBox x={470} y={276} width={266} height={40} 
stroke={token.colorBorderSecondary} />
+          <SvgLabel x={484} y={293} fill={token.colorText}>
+            {t('resourceModel.svgCrossLine1')}
+          </SvgLabel>
+          <SvgLabel x={484} y={309} fill={token.colorTextSecondary} muted>
+            {t('resourceModel.svgCrossLine2')}
+          </SvgLabel>
+        </svg>
+
+        <Title level={5} style={{ marginTop: 24 }}>
+          {t('resourceModel.rulesTitle')}
+        </Title>
+        <Space direction="vertical" size={6}>
+          <Text>{t('resourceModel.rule1')}</Text>
+          <Text>{t('resourceModel.rule2')}</Text>
+          <Text>{t('resourceModel.rule3')}</Text>
+          <Text>{t('resourceModel.rule4')}</Text>
+        </Space>
+      </Card>
+    </div>
+  );
+};
+
+export default ResourceModelPage;
diff --git a/web/src/pages/studio/__tests__/ResourceModel.test.tsx 
b/web/src/pages/studio/__tests__/ResourceModel.test.tsx
new file mode 100644
index 000000000..6968464de
--- /dev/null
+++ b/web/src/pages/studio/__tests__/ResourceModel.test.tsx
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import { App } from 'antd';
+import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
+import translations from '../../../i18n/translations';
+import ResourceModelPage from '../ResourceModel';
+
+beforeAll(() => {
+  Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    value: vi.fn().mockImplementation((query: string) => ({
+      matches: false,
+      media: query,
+      onchange: null,
+      addListener: vi.fn(),
+      removeListener: vi.fn(),
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      dispatchEvent: vi.fn(),
+    })),
+  });
+});
+
+const renderPage = (lang: 'zh' | 'en') => {
+  localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
+  return render(
+    <App>
+      <LangProvider>
+        <ResourceModelPage />
+      </LangProvider>
+    </App>,
+  );
+};
+
+describe('ResourceModel page', () => {
+  it('renders the topology diagram and the summary banner', () => {
+    const { getByTestId } = renderPage('zh');
+    expect(getByTestId('resource-model-topology').tagName).toBe('svg');
+    expect(getByTestId('resource-model-banner')).toBeInTheDocument();
+  });
+
+  // The creation-time decision flow was a backend implementation detail; the 
rule now lives in
+  // the ResourceRuleHelp modal on the create forms, so the page must not 
carry the diagram back.
+  it('leaves the creation-decision flow to the create forms', () => {
+    const { queryByTestId, container } = renderPage('zh');
+    expect(queryByTestId('resource-model-flow')).toBeNull();
+    expect(container.textContent).not.toContain('创建时的判定');
+  });
+
+  // An untranslated label falls back to the raw key, so a missing entry shows 
up as
+  // "resourceModel.xxx" in the rendered text.
+  it.each(['zh', 'en'] as const)('resolves every %s label without leaking i18n 
keys', (lang) => {
+    const { container } = renderPage(lang);
+    expect(container.textContent).not.toContain('resourceModel.');
+    expect(container.textContent?.length).toBeGreaterThan(500);
+  });
+
+  it('keeps the whole resourceModel catalogue filled in both languages', () => 
{
+    const keys = Object.keys(translations).filter((key) => 
key.startsWith('resourceModel.'));
+    expect(keys.length).toBeGreaterThan(30);
+    keys.forEach((key) => {
+      expect(translations[key].zh.trim(), key).not.toBe('');
+      expect(translations[key].en.trim(), key).not.toBe('');
+    });
+  });
+
+  // jsdom has no layout, so the SVG boxes cannot be measured here. The 
budgets come from a
+  // browser measurement of the 14px UI font (~7px per Latin char, ~14px per 
CJK char) against
+  // the usable width of each hand-drawn box; a label that grows past it would 
be clipped.
+  const WIDE_CHAR = /[\u2E80-\u9FFF\u3000-\u303F\uF900-\uFAFF\uFF00-\uFF60]/;
+  const estimatedWidth = (text: string) =>
+    [...text].reduce((sum, ch) => sum + (WIDE_CHAR.test(ch) ? 14 : 7), 0);
+
+  const SVG_LABEL_BUDGETS: Array<[string, number]> = [
+    // topology: resource chips (169 / 242 wide, text starts 10 inside)
+    ['resourceModel.svgChipOrdersTrade', 150],
+    ['resourceModel.svgChipPaymentsTrade', 150],
+    ['resourceModel.svgChipOrdersLogistics', 150],
+    ['resourceModel.svgChipOrdersStaging', 224],
+    // domain titles and body notes
+    ['resourceModel.svgDomainProd', 398],
+    ['resourceModel.svgDomainStaging', 242],
+    ['resourceModel.svgMergeNote1', 398],
+    ['resourceModel.svgMergeNote2', 398],
+    ['resourceModel.svgCrossDomainNote', 242],
+    ['resourceModel.svgCrossDomainNote2', 242],
+    ['resourceModel.svgCrossLine1', 242],
+    ['resourceModel.svgCrossLine2', 242],
+    ['resourceModel.svgConflictLine1', 374],
+    ['resourceModel.svgConflictLine2', 374],
+    // instance boxes (193 / 266 wide, text starts 26 inside)
+    ['resourceModel.svgInstTrade', 155],
+    ['resourceModel.svgInstLogistics', 155],
+    ['resourceModel.svgInstStaging', 228],
+    ['resourceModel.svgBoundNote', 155],
+  ];
+
+  it.each(SVG_LABEL_BUDGETS)('keeps %s inside its drawn box', (key, budget) => 
{
+    (['zh', 'en'] as const).forEach((lang) => {
+      const width = estimatedWidth(translations[key][lang]);
+      expect(width, `${key}.${lang} = 
"${translations[key][lang]}"`).toBeLessThanOrEqual(budget);
+    });
+  });
+});

Reply via email to