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 fe785b810 feat(web): fold the resource model page into the create-form 
rule modal (#7355)
fe785b810 is described below

commit fe785b81044dd7dd409d733448accd8267de3ecf
Author: lizhimins <[email protected]>
AuthorDate: Mon Oct 26 15:06:31 2026 +0800

    feat(web): fold the resource model page into the create-form rule modal 
(#7355)
    
    Drop the /studio/resource-model route, its menu entry and the page 
component: the
    ownership topology and the four rules now render inside the question-mark 
modal on
    the create instance, topic and group forms, widened to 860px with a 
scrollable body.
    The modal title becomes the rule name and the tooltip reuses the aria label 
so the
    two stay distinguishable to assistive tech.
    
    The modal now ends with the four ownership rules. The three-outcome list is 
dropped
    because rule 1 already says a foreign owner is reported at creation, so the 
list only
    restated it.
---
 web/src/App.tsx                                    |   2 -
 web/src/components/ResourceOwnershipTopology.tsx   | 341 +++++++++++++++++++
 web/src/components/ResourceRuleHelp.tsx            |  77 ++---
 .../components/__tests__/ResourceRuleHelp.test.tsx | 107 ++++--
 web/src/i18n/translations.ts                       |  42 +--
 web/src/layouts/MainLayout.tsx                     |   7 -
 .../pages/instance/__tests__/ConsumerPage.test.tsx |   2 +-
 .../pages/instance/__tests__/InstancePage.test.tsx |   2 +-
 .../pages/instance/__tests__/TopicPage.test.tsx    |   2 +-
 web/src/pages/studio/ResourceModel.tsx             | 370 ---------------------
 .../pages/studio/__tests__/ResourceModel.test.tsx  | 122 -------
 11 files changed, 462 insertions(+), 612 deletions(-)

diff --git a/web/src/App.tsx b/web/src/App.tsx
index aaaef366a..4e7284fe3 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -55,7 +55,6 @@ 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';
 
@@ -237,7 +236,6 @@ 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/components/ResourceOwnershipTopology.tsx 
b/web/src/components/ResourceOwnershipTopology.tsx
new file mode 100644
index 000000000..3a54d694e
--- /dev/null
+++ b/web/src/components/ResourceOwnershipTopology.tsx
@@ -0,0 +1,341 @@
+/*
+ * 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 { theme } from 'antd';
+import { useLang } from '../i18n/LangContext';
+import { useTheme } from '../theme/useTheme';
+
+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 环境,含同名共存正例与归属冲突反例。
+ * 原为独立「资源模型」页的主体,2026-10-26 起只出现在创建表单的规则弹窗里。
+ */
+const ResourceOwnershipTopology = () => {
+  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 (
+    <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-ownership-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>
+  );
+};
+
+export default ResourceOwnershipTopology;
diff --git a/web/src/components/ResourceRuleHelp.tsx 
b/web/src/components/ResourceRuleHelp.tsx
index 85302aa43..eb75a2df3 100644
--- a/web/src/components/ResourceRuleHelp.tsx
+++ b/web/src/components/ResourceRuleHelp.tsx
@@ -19,41 +19,24 @@ import { useState } from 'react';
 import { Button, Modal, Space, Tooltip, Typography, theme } from 'antd';
 import { QuestionCircleOutlined } from '@ant-design/icons';
 import { useLang } from '../i18n/LangContext';
+import ResourceOwnershipTopology from './ResourceOwnershipTopology';
 
 const { Text } = Typography;
 
 const FONT_SIZE = 14;
 
 /**
- * 创建实例 / Topic / Group 表单上的规则入口:问号点开弹窗,讲清名字的唯一性边界与
- * 创建时的三种结果。文案与「资源模型」页共用 `resourceModel.*` 一套口径。
+ * 创建实例 / Topic / Group 表单上的规则入口:问号点开弹窗,讲清名字的唯一性边界、
+ * 归属拓扑与创建时的三种结果。2026-10-26 起这套说明没有独立页面,只在这里出现。
  */
 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')}>
+      <Tooltip title={t('resourceModel.helpAria')}>
         <span
           role="button"
           tabIndex={0}
@@ -78,39 +61,39 @@ const ResourceRuleHelp = () => {
       <Modal
         title={t('resourceModel.modalTitle')}
         open={open}
-        width={520}
+        width={860}
         onCancel={() => setOpen(false)}
+        styles={{
+          body: { maxHeight: 'calc(100vh - 260px)', overflowY: 'auto', 
paddingRight: 8 },
+        }}
         footer={<Button onClick={() => 
setOpen(false)}>{t('common.close')}</Button>}
       >
-        <Space direction="vertical" size={14} style={{ width: '100%', 
marginTop: 8 }}>
+        <Space direction="vertical" size={16} 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>
+
+          <ResourceOwnershipTopology />
+
+          <div>
+            <Text strong style={{ fontSize: FONT_SIZE }}>
+              {t('resourceModel.rulesTitle')}
+            </Text>
+            <Space direction="vertical" size={4} style={{ display: 'flex', 
marginTop: 6 }}>
+              <Text style={{ fontSize: FONT_SIZE, lineHeight: 1.7 }}>
+                {t('resourceModel.rule1')}
+              </Text>
+              <Text style={{ fontSize: FONT_SIZE, lineHeight: 1.7 }}>
+                {t('resourceModel.rule2')}
+              </Text>
+              <Text style={{ fontSize: FONT_SIZE, lineHeight: 1.7 }}>
+                {t('resourceModel.rule3')}
+              </Text>
+              <Text style={{ fontSize: FONT_SIZE, lineHeight: 1.7 }}>
+                {t('resourceModel.rule4')}
+              </Text>
             </Space>
-          ))}
+          </div>
         </Space>
       </Modal>
     </>
diff --git a/web/src/components/__tests__/ResourceRuleHelp.test.tsx 
b/web/src/components/__tests__/ResourceRuleHelp.test.tsx
index afbf21736..57e4ef3b8 100644
--- a/web/src/components/__tests__/ResourceRuleHelp.test.tsx
+++ b/web/src/components/__tests__/ResourceRuleHelp.test.tsx
@@ -40,6 +40,9 @@ beforeAll(() => {
   });
 });
 
+const MODAL_TITLE = translations['resourceModel.modalTitle'].zh;
+const HELP_ARIA = translations['resourceModel.helpAria'].zh;
+
 const renderHelp = (lang: 'zh' | 'en') => {
   localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
   return render(
@@ -51,11 +54,12 @@ const renderHelp = (lang: 'zh' | 'en') => {
   );
 };
 
-const OUTCOME_KEYS = [
-  'resourceModel.outcomeFreeTitle',
-  'resourceModel.outcomeOwnTitle',
-  'resourceModel.outcomeOtherTitle',
-] as const;
+const openHelp = async (user: ReturnType<typeof userEvent.setup>, lang: 'zh' | 
'en' = 'zh') => {
+  await user.click(
+    screen.getByRole('button', { name: 
translations['resourceModel.helpAria'][lang] }),
+  );
+  return screen.findByRole('dialog', { name: 
translations['resourceModel.modalTitle'][lang] });
+};
 
 describe('ResourceRuleHelp', () => {
   beforeEach(() => {
@@ -65,30 +69,33 @@ describe('ResourceRuleHelp', () => {
   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();
+    expect(screen.queryByRole('dialog')).toBeNull();
+    expect(await openHelp(user)).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: '查看资源命名与归属规则' });
+    const trigger = screen.getByRole('button', { name: HELP_ARIA });
     trigger.focus();
     await user.keyboard('{Enter}');
-    expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+    expect(await screen.findByText(MODAL_TITLE)).toBeInTheDocument();
   });
 
-  it('spells out the three creation outcomes', async () => {
+  // The standalone resource model page is gone (user decision 2026-10-26); 
the topology and the
+  // four ownership rules now live only inside this modal.
+  it('carries the ownership topology and the four rules into the modal', async 
() => {
     const user = userEvent.setup();
     renderHelp('zh');
-    await user.click(screen.getByRole('button', { name: '查看资源命名与归属规则' }));
+    await openHelp(user);
 
-    OUTCOME_KEYS.forEach((key) => {
-      expect(screen.getByText(translations[key].zh)).toBeInTheDocument();
+    
expect(screen.getByTestId('resource-ownership-topology').tagName).toBe('svg');
+    
expect(screen.getByText(translations['resourceModel.rulesTitle'].zh)).toBeInTheDocument();
+    (['rule1', 'rule2', 'rule3', 'rule4'] as const).forEach((suffix) => {
+      
expect(screen.getByText(translations[`resourceModel.${suffix}`].zh)).toBeInTheDocument();
     });
-    
expect(screen.getByText(translations['resourceModel.outcomeOtherDesc'].zh)).toBeInTheDocument();
+    // The three-outcome list was dropped as redundant with rule1 (user 
decision 2026-10-26).
+    expect(screen.queryByText('创建时的三种结果')).toBeNull();
   });
 
   it.each(['zh', 'en'] as const)(
@@ -96,13 +103,7 @@ describe('ResourceRuleHelp', () => {
     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(await openHelp(user, lang)).toBeInTheDocument();
       expect(baseElement.textContent).not.toContain('resourceModel.');
     },
   );
@@ -110,8 +111,7 @@ describe('ResourceRuleHelp', () => {
   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();
+    await openHelp(user);
 
     // antd pads a two-character CJK button label with a space, so match it 
loosely.
     await user.click(screen.getByRole('button', { name: /关\s*闭/ }));
@@ -122,4 +122,61 @@ describe('ResourceRuleHelp', () => {
       
expect(document.querySelector('.ant-modal-mask')?.className).toContain('ant-fade-leave'),
     );
   });
+
+  it('keeps the whole resourceModel catalogue filled in both languages', () => 
{
+    const keys = Object.keys(translations).filter((key) => 
key.startsWith('resourceModel.'));
+    // 27 keys since the outcomes list was dropped: 19 topology labels + 5 
rule entries +
+    // modalTitle / modalIntro / helpAria.
+    expect(keys.length).toBeGreaterThan(25);
+    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 isWideChar = (ch: string) => {
+    const code = ch.codePointAt(0) ?? 0;
+    return (
+      (code >= 0x2e80 && code <= 0x9fff) ||
+      (code >= 0x3000 && code <= 0x303f) ||
+      (code >= 0xf900 && code <= 0xfaff) ||
+      (code >= 0xff00 && code <= 0xff60)
+    );
+  };
+  const estimatedWidth = (text: string) =>
+    [...text].reduce((sum, ch) => sum + (isWideChar(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);
+    });
+  });
 });
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index dc4d861b4..990bd636c 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -40,9 +40,8 @@ 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 ──
   'common.search': { zh: '搜索...', en: 'Search...' },
   'common.searchPlaceholder': { zh: '搜索页面、功能...', en: 'Search pages, 
features...' },
   'common.create': { zh: '新建', en: 'Create' },
@@ -3998,20 +3997,7 @@ 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(资源命名与归属:创建表单问号弹窗内的说明)───
   'resourceModel.svgTopologyAria': {
     zh: '生产与预发两套 nameserver 环境下 Topic 名字的归属示意',
     en: 'Topic ownership across the production and staging NameServer 
environments',
@@ -4080,12 +4066,14 @@ const translations: Record<string, Record<Lang, 
string>> = {
     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.modalTitle': {
+    zh: '资源命名与归属规则',
+    en: 'Naming and ownership rules',
+  },
   'resourceModel.modalIntro': {
     zh:
       '一个名字在「nameserver 集群 + broker 集群」这个范围内唯一。实例在创建时选定 nameserver ' +
@@ -4094,24 +4082,6 @@ const translations: Record<string, Record<Lang, string>> 
= {
       '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 7d26943c1..5a2344998 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -40,7 +40,6 @@ import {
   PaperPlaneTilt,
   Notebook,
   Warning,
-  TreeStructure,
 } from '@phosphor-icons/react';
 import { useLang } from '../i18n/LangContext';
 import { useTheme } from '../theme/useTheme';
@@ -234,11 +233,6 @@ 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'),
-          },
         ],
       },
       {
@@ -280,7 +274,6 @@ 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 c48281ad0..314490473 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -555,7 +555,7 @@ describe('Consumer page', () => {
     const dialog = await screen.findByRole('dialog');
 
     await user.click(within(dialog).getByRole('button', { name: '查看资源命名与归属规则' 
}));
-    expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+    expect(await screen.findByText('资源命名与归属规则')).toBeInTheDocument();
   });
 
   it('submits the canonical global delivery order type', async () => {
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx 
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 22ae808e3..f51739779 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -378,7 +378,7 @@ describe('InstancePage', () => {
     const dialog = await screen.findByRole('dialog');
 
     await user.click(within(dialog).getByRole('button', { name: '查看资源命名与归属规则' 
}));
-    expect(await screen.findByText('创建时的归属规则')).toBeInTheDocument();
+    expect(await screen.findByText('资源命名与归属规则')).toBeInTheDocument();
   });
 
   it('loads server-filtered results when the search or type changes', async () 
=> {
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx 
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 512d85264..3d3f9febd 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -309,7 +309,7 @@ describe('TopicPage', () => {
     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();
+    expect(await screen.findByText('资源命名与归属规则')).toBeInTheDocument();
   });
 
   it('gives each of the four row actions its own color', async () => {
diff --git a/web/src/pages/studio/ResourceModel.tsx 
b/web/src/pages/studio/ResourceModel.tsx
deleted file mode 100644
index 963dc5791..000000000
--- a/web/src/pages/studio/ResourceModel.tsx
+++ /dev/null
@@ -1,370 +0,0 @@
-/*
- * 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
deleted file mode 100644
index 6968464de..000000000
--- a/web/src/pages/studio/__tests__/ResourceModel.test.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- * 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