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

commit 6538252ae32b4fa057971842839b6bae6be1c78c
Author: zhaohai <[email protected]>
AuthorDate: Wed Jul 22 19:23:19 2026 +0800

    feat: add consumer group management page (#480)
    
    Add consumer group management page.
---
 web/src/App.tsx                                    |   2 +
 web/src/i18n/translations.ts                       |  82 ++-
 web/src/pages/studio/GroupManagement.tsx           | 551 +++++++++++++++++++++
 .../studio/__tests__/GroupManagement.test.tsx      |  97 ++++
 4 files changed, 709 insertions(+), 23 deletions(-)

diff --git a/web/src/App.tsx b/web/src/App.tsx
index f4ca3341..c23820a0 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -33,6 +33,7 @@ import SystemAlertsPage from './pages/ops/systemAlerts';
 import AuditPage from './pages/ops/audit';
 import AiPage from './pages/ai';
 import SettingsPage from './pages/settings';
+import GroupManagementPage from './pages/studio/GroupManagement';
 import BrokerClusterPage from './pages/studio/BrokerCluster';
 import SslSettingsPage from './pages/studio/SslSettings';
 import AlertManagementPage from './pages/studio/AlertManagement';
@@ -61,6 +62,7 @@ function App() {
         <Route path="ops/audit" element={<AuditPage />} />
         <Route path="ai" element={<AiPage />} />
         <Route path="settings" element={<SettingsPage />} />
+        <Route path="studio/group-management" element={<GroupManagementPage 
/>} />
         <Route path="studio/broker-cluster" element={<BrokerClusterPage />} />
         <Route path="studio/ssl-settings" element={<SslSettingsPage />} />
         <Route path="studio/alert-management" element={<AlertManagementPage 
/>} />
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index d14c1fd1..6823e5d4 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -623,6 +623,65 @@ const translations: Record<string, Record<Lang, string>> = 
{
   'proxy.addrRequired': { zh: '请输入代理地址', en: 'Proxy address is required' },
   'proxy.noConfigData': { zh: '无配置数据', en: 'No config data' },
 
+  // ─── Broker Cluster ───
+  'brokerCluster.title': { zh: 'Broker 集群', en: 'Broker Cluster' },
+  'brokerCluster.k8sCluster': { zh: 'K8s 集群', en: 'K8s Cluster' },
+  'brokerCluster.brokerName': { zh: 'Broker 名称', en: 'Broker Name' },
+  'brokerCluster.status': { zh: '状态', en: 'Status' },
+  'brokerCluster.statusRunning': { zh: '运行中', en: 'Running' },
+  'brokerCluster.statusReadonly': { zh: '只读', en: 'Read Only' },
+  'brokerCluster.statusMaintenance': { zh: '维护中', en: 'Maintenance' },
+  'brokerCluster.version': { zh: '版本', en: 'Version' },
+  'brokerCluster.diskUsage': { zh: '磁盘使用', en: 'Disk Usage' },
+  'brokerCluster.tpsIn': { zh: '入站 TPS', en: 'TPS In' },
+  'brokerCluster.tpsOut': { zh: '出站 TPS', en: 'TPS Out' },
+  'brokerCluster.config': { zh: '配置', en: 'Config' },
+  'brokerCluster.restart': { zh: '重启', en: 'Restart' },
+  'brokerCluster.manual': { zh: '手动', en: 'Manual' },
+  'brokerCluster.createCluster': { zh: '创建集群', en: 'Create Cluster' },
+  'brokerCluster.nsName': { zh: 'NameServer 名称', en: 'NameServer Name' },
+  'brokerCluster.nsManagement': { zh: 'NameServer 管理', en: 'NameServer 
Management' },
+  'brokerCluster.brokerManagement': { zh: 'Broker 管理', en: 'Broker Management' 
},
+  'brokerCluster.proxyName': { zh: 'Proxy 名称', en: 'Proxy Name' },
+  'brokerCluster.proxyManagement': { zh: 'Proxy 管理', en: 'Proxy Management' },
+  'brokerCluster.httpAddr': { zh: 'HTTP 地址', en: 'HTTP Address' },
+  'brokerCluster.grpcAddr': { zh: 'gRPC 地址', en: 'gRPC Address' },
+  'brokerCluster.connections': { zh: '连接数', en: 'Connections' },
+
+  // ─── Group Management ───
+  'groupMgmt.title': { zh: '消费组管理', en: 'Consumer Group Management' },
+  'groupMgmt.groupName': { zh: '消费组名称', en: 'Group Name' },
+  'groupMgmt.namespace': { zh: '命名空间', en: 'Namespace' },
+  'groupMgmt.cluster': { zh: '集群', en: 'Cluster' },
+  'groupMgmt.onlineInstances': { zh: '在线实例', en: 'Online Instances' },
+  'groupMgmt.consumeMode': { zh: '消费模式', en: 'Consume Mode' },
+  'groupMgmt.clustering': { zh: '集群消费', en: 'Clustering' },
+  'groupMgmt.broadcasting': { zh: '广播消费', en: 'Broadcasting' },
+  'groupMgmt.diff': { zh: '堆积量', en: 'Diff' },
+  'groupMgmt.backlogAlert': { zh: '堆积告警', en: 'Backlog Alert' },
+  'groupMgmt.stopped': { zh: '已停止', en: 'Stopped' },
+  'groupMgmt.createGroup': { zh: '创建消费组', en: 'Create Group' },
+  'groupMgmt.searchPlaceholder': { zh: '搜索消费组', en: 'Search group' },
+  'groupMgmt.manual': { zh: '手动', en: 'Manual' },
+  'groupMgmt.overview': { zh: '概览', en: 'Overview' },
+  'groupMgmt.totalDiff': { zh: '总堆积量', en: 'Total Diff' },
+  'groupMgmt.subscribedTopics': { zh: '订阅主题', en: 'Subscribed Topics' },
+  'groupMgmt.online': { zh: '在线', en: 'Online' },
+  'groupMgmt.consumeType': { zh: '消费类型', en: 'Consume Type' },
+  'groupMgmt.consumeDelay': { zh: '消费延迟', en: 'Consume Delay' },
+  'groupMgmt.maxRetry': { zh: '最大重试', en: 'Max Retry' },
+  'groupMgmt.createdAt': { zh: '创建时间', en: 'Created At' },
+  'groupMgmt.subscription': { zh: '订阅关系', en: 'Subscription' },
+  'groupMgmt.topic': { zh: '主题', en: 'Topic' },
+  'groupMgmt.consistency': { zh: '一致性', en: 'Consistency' },
+  'groupMgmt.consistent': { zh: '一致', en: 'Consistent' },
+  'groupMgmt.inconsistent': { zh: '不一致', en: 'Inconsistent' },
+  'groupMgmt.subMode': { zh: '订阅模式', en: 'Sub Mode' },
+  'groupMgmt.expression': { zh: '过滤表达式', en: 'Expression' },
+  'groupMgmt.viewDistribution': { zh: '查看分布', en: 'View Distribution' },
+  'groupMgmt.instanceId': { zh: '实例 ID', en: 'Instance ID' },
+  'groupMgmt.consumeProgress': { zh: '消费进度', en: 'Consume Progress' },
+
   // ─── SSL Settings ───
   'ssl.title': { zh: 'SSL/TLS 设置', en: 'SSL/TLS Settings' },
   'ssl.info': { zh: 'SSL/TLS 配置', en: 'SSL/TLS Configuration' },
@@ -877,29 +936,6 @@ const translations: Record<string, Record<Lang, string>> = 
{
   'common.default': { zh: '默认', en: 'Default' },
 
   // ─── BrokerCluster ───
-  'brokerCluster.title': { zh: 'RocketMQ 集群', en: 'RocketMQ Cluster' },
-  'brokerCluster.k8sCluster': { zh: 'K8s 集群名称', en: 'K8s Cluster' },
-  'brokerCluster.brokerName': { zh: 'Broker 名称', en: 'Broker Name' },
-  'brokerCluster.status': { zh: '运行状态', en: 'Status' },
-  'brokerCluster.statusRunning': { zh: '运行中', en: 'Running' },
-  'brokerCluster.statusReadonly': { zh: '只读', en: 'Readonly' },
-  'brokerCluster.statusMaintenance': { zh: '维护中', en: 'Maintenance' },
-  'brokerCluster.version': { zh: '版本', en: 'Version' },
-  'brokerCluster.diskUsage': { zh: '磁盘使用率', en: 'Disk Usage' },
-  'brokerCluster.tpsIn': { zh: 'TPS 入流量', en: 'TPS In' },
-  'brokerCluster.tpsOut': { zh: 'TPS 出流量', en: 'TPS Out' },
-  'brokerCluster.config': { zh: '配置', en: 'Config' },
-  'brokerCluster.restart': { zh: '重启', en: 'Restart' },
-  'brokerCluster.manual': { zh: '手动', en: 'Manual' },
-  'brokerCluster.createCluster': { zh: '新建集群', en: 'Create Cluster' },
-  'brokerCluster.nsName': { zh: 'NameServer 名称', en: 'NameServer Name' },
-  'brokerCluster.nsManagement': { zh: 'NameServer 管理', en: 'NameServer 
Management' },
-  'brokerCluster.brokerManagement': { zh: 'Broker 管理', en: 'Broker Management' 
},
-  'brokerCluster.proxyName': { zh: 'Proxy 名称', en: 'Proxy Name' },
-  'brokerCluster.proxyManagement': { zh: 'Proxy 管理', en: 'Proxy Management' },
-  'brokerCluster.httpAddr': { zh: 'HTTP 地址', en: 'HTTP Address' },
-  'brokerCluster.grpcAddr': { zh: 'gRPC 地址', en: 'gRPC Address' },
-  'brokerCluster.connections': { zh: '连接数', en: 'Connections' },
 };
 
 export default translations;
diff --git a/web/src/pages/studio/GroupManagement.tsx 
b/web/src/pages/studio/GroupManagement.tsx
new file mode 100644
index 00000000..4c4976af
--- /dev/null
+++ b/web/src/pages/studio/GroupManagement.tsx
@@ -0,0 +1,551 @@
+/*
+ * 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 {
+  Table,
+  Button,
+  Input,
+  Tag,
+  Modal,
+  Tabs,
+  Card,
+  Row,
+  Col,
+  Descriptions,
+  Space,
+  Switch,
+} from 'antd';
+import { MagnifyingGlass, Plus, ArrowClockwise, Users, Eye } from 
'@phosphor-icons/react';
+import { useLang } from '../../i18n/LangContext';
+
+// ─── Types ──────────────────────────────────────────────────────
+interface GroupRecord {
+  key: string;
+  group: string;
+  namespace: string;
+  cluster: string;
+  count: number;
+  consumeType: string;
+  messageModel: 'CLUSTERING' | 'BROADCASTING';
+  diff: number;
+  status: 'running' | 'warning' | 'stopped';
+}
+
+interface SubscriptionRecord {
+  key: string;
+  topic: string;
+  consistency: 'consistent' | 'inconsistent';
+  subMode: string;
+  expression: string;
+}
+
+interface InstanceRecord {
+  key: string;
+  instanceId: string;
+  address: string;
+  version: string;
+  status: string;
+}
+
+interface ProgressRecord {
+  key: string;
+  topic: string;
+  queueId: string;
+  brokerOffset: string;
+  consumerOffset: string;
+  diff: number;
+}
+
+// ─── Mock Data ──────────────────────────────────────────────────
+const groupData: GroupRecord[] = [
+  {
+    key: '1',
+    group: 'order-consumer-group',
+    namespace: 'default',
+    cluster: 'cluster-production',
+    count: 4,
+    consumeType: 'PUSH',
+    messageModel: 'CLUSTERING',
+    diff: 1280,
+    status: 'running',
+  },
+  {
+    key: '2',
+    group: 'payment-consumer-group',
+    namespace: 'default',
+    cluster: 'cluster-production',
+    count: 2,
+    consumeType: 'PUSH',
+    messageModel: 'CLUSTERING',
+    diff: 0,
+    status: 'running',
+  },
+  {
+    key: '3',
+    group: 'inventory-sync-group',
+    namespace: 'ns-warehouse',
+    cluster: 'cluster-warehouse',
+    count: 3,
+    consumeType: 'PUSH',
+    messageModel: 'CLUSTERING',
+    diff: 56800,
+    status: 'warning',
+  },
+  {
+    key: '4',
+    group: 'log-processor-group',
+    namespace: 'default',
+    cluster: 'cluster-production',
+    count: 1,
+    consumeType: 'PUSH',
+    messageModel: 'BROADCASTING',
+    diff: 0,
+    status: 'running',
+  },
+  {
+    key: '5',
+    group: 'notification-group',
+    namespace: 'ns-notify',
+    cluster: 'cluster-production',
+    count: 2,
+    consumeType: 'PUSH',
+    messageModel: 'CLUSTERING',
+    diff: 3,
+    status: 'running',
+  },
+];
+
+const subscriptionData: SubscriptionRecord[] = [
+  { key: '1', topic: 'ORDER_TOPIC', consistency: 'consistent', subMode: 'TAG', 
expression: '*' },
+  {
+    key: '2',
+    topic: 'PAYMENT_TOPIC',
+    consistency: 'consistent',
+    subMode: 'TAG',
+    expression: 'pay_success || pay_fail',
+  },
+  {
+    key: '3',
+    topic: 'INVENTORY_TOPIC',
+    consistency: 'inconsistent',
+    subMode: 'TAG',
+    expression: 'stock_update',
+  },
+];
+
+const instanceData: InstanceRecord[] = [
+  {
+    key: '1',
+    instanceId: 'cid-001',
+    address: '10.0.1.10:10911',
+    version: '5.3.0',
+    status: 'online',
+  },
+  {
+    key: '2',
+    instanceId: 'cid-002',
+    address: '10.0.1.11:10911',
+    version: '5.3.0',
+    status: 'online',
+  },
+  {
+    key: '3',
+    instanceId: 'cid-003',
+    address: '10.0.1.12:10911',
+    version: '5.3.0',
+    status: 'online',
+  },
+  {
+    key: '4',
+    instanceId: 'cid-004',
+    address: '10.0.1.13:10911',
+    version: '5.3.0',
+    status: 'online',
+  },
+];
+
+const progressData: ProgressRecord[] = [
+  {
+    key: '1',
+    topic: 'ORDER_TOPIC',
+    queueId: '0',
+    brokerOffset: '125,680',
+    consumerOffset: '125,400',
+    diff: 280,
+  },
+  {
+    key: '2',
+    topic: 'ORDER_TOPIC',
+    queueId: '1',
+    brokerOffset: '98,200',
+    consumerOffset: '98,200',
+    diff: 0,
+  },
+  {
+    key: '3',
+    topic: 'PAYMENT_TOPIC',
+    queueId: '0',
+    brokerOffset: '45,300',
+    consumerOffset: '45,300',
+    diff: 0,
+  },
+  {
+    key: '4',
+    topic: 'INVENTORY_TOPIC',
+    queueId: '0',
+    brokerOffset: '230,100',
+    consumerOffset: '173,300',
+    diff: 56800,
+  },
+];
+
+// ─── Component ──────────────────────────────────────────────────
+const GroupManagementPage = () => {
+  const [searchText, setSearchText] = useState('');
+  const [modalVisible, setModalVisible] = useState(false);
+  const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
+  const [autoRefresh, setAutoRefresh] = useState(false);
+  const { t } = useLang();
+
+  const handleViewDetail = (groupName: string) => {
+    setSelectedGroup(groupName);
+    setModalVisible(true);
+  };
+
+  const columns = [
+    {
+      title: t('groupMgmt.groupName'),
+      dataIndex: 'group',
+      key: 'group',
+      render: (text: string) => (
+        <a onClick={() => handleViewDetail(text)} style={{ color: '#1677ff', 
fontWeight: 500 }}>
+          {text}
+        </a>
+      ),
+    },
+    { title: t('groupMgmt.namespace'), dataIndex: 'namespace', key: 
'namespace' },
+    { title: t('groupMgmt.cluster'), dataIndex: 'cluster', key: 'cluster' },
+    {
+      title: t('groupMgmt.onlineInstances'),
+      dataIndex: 'count',
+      key: 'count',
+      render: (count: number) => <span style={{ fontWeight: 500 
}}>{count}</span>,
+    },
+    {
+      title: t('groupMgmt.consumeMode'),
+      dataIndex: 'messageModel',
+      key: 'messageModel',
+      render: (mode: string) => (
+        <Tag color={mode === 'CLUSTERING' ? 'blue' : 'orange'}>
+          {mode === 'CLUSTERING' ? t('groupMgmt.clustering') : 
t('groupMgmt.broadcasting')}
+        </Tag>
+      ),
+    },
+    {
+      title: t('groupMgmt.diff'),
+      dataIndex: 'diff',
+      key: 'diff',
+      render: (diff: number) => (
+        <span
+          style={{
+            color: diff > 10000 ? '#ff4d4f' : diff > 0 ? '#fa8c16' : '#52c41a',
+            fontWeight: 500,
+          }}
+        >
+          {diff.toLocaleString()}
+        </span>
+      ),
+    },
+    {
+      title: t('brokerCluster.status'),
+      dataIndex: 'status',
+      key: 'status',
+      render: (status: string) => {
+        const config: Record<string, { color: string; label: string }> = {
+          running: { color: 'success', label: t('brokerCluster.statusRunning') 
},
+          warning: { color: 'warning', label: t('groupMgmt.backlogAlert') },
+          stopped: { color: 'error', label: t('groupMgmt.stopped') },
+        };
+        const { color, label } = config[status] || config.running;
+        return <Tag color={color}>{label}</Tag>;
+      },
+    },
+    {
+      title: t('common.actions'),
+      key: 'action',
+      render: (_: unknown, record: GroupRecord) => (
+        <Space size="small">
+          <Button type="link" size="small" onClick={() => 
handleViewDetail(record.group)}>
+            {t('common.detail')}
+          </Button>
+          <Button type="link" size="small">
+            {t('brokerCluster.config')}
+          </Button>
+        </Space>
+      ),
+    },
+  ];
+
+  const subscriptionColumns = [
+    {
+      title: t('groupMgmt.topic'),
+      dataIndex: 'topic',
+      key: 'topic',
+      render: (text: string) => <span style={{ fontWeight: 500 
}}>{text}</span>,
+    },
+    {
+      title: t('groupMgmt.consistency'),
+      dataIndex: 'consistency',
+      key: 'consistency',
+      render: (consistency: string) => (
+        <Tag color={consistency === 'consistent' ? 'success' : 'warning'}>
+          {consistency === 'consistent' ? t('groupMgmt.consistent') : 
t('groupMgmt.inconsistent')}
+        </Tag>
+      ),
+    },
+    { title: t('groupMgmt.subMode'), dataIndex: 'subMode', key: 'subMode' },
+    {
+      title: t('groupMgmt.expression'),
+      dataIndex: 'expression',
+      key: 'expression',
+      render: (text: string) => (
+        <code style={{ background: '#f5f5f5', padding: '2px 6px', 
borderRadius: 4, fontSize: 12 }}>
+          {text}
+        </code>
+      ),
+    },
+    {
+      title: t('common.actions'),
+      key: 'action',
+      render: () => (
+        <Button type="link" size="small" icon={<Eye size={14} />}>
+          {t('groupMgmt.viewDistribution')}
+        </Button>
+      ),
+    },
+  ];
+
+  return (
+    <div style={{ padding: 0 }}>
+      <div
+        style={{
+          display: 'flex',
+          justifyContent: 'space-between',
+          alignItems: 'center',
+          marginBottom: 20,
+        }}
+      >
+        <h2
+          style={{
+            fontSize: 20,
+            fontWeight: 600,
+            margin: 0,
+            display: 'flex',
+            alignItems: 'center',
+          }}
+        >
+          <Users size={22} style={{ marginRight: 8, color: '#1677ff' }} />
+          {t('groupMgmt.title')}
+        </h2>
+        <Space size="middle">
+          <Input
+            placeholder={t('groupMgmt.searchPlaceholder')}
+            prefix={<MagnifyingGlass size={14} />}
+            value={searchText}
+            onChange={(e) => setSearchText(e.target.value)}
+            style={{ width: 240 }}
+            allowClear
+          />
+          <Button type="primary" icon={<Plus size={14} />}>
+            {t('groupMgmt.createGroup')}
+          </Button>
+          <Switch
+            checked={autoRefresh}
+            onChange={setAutoRefresh}
+            checkedChildren={t('common.autoRefresh')}
+            unCheckedChildren={t('groupMgmt.manual')}
+            size="small"
+          />
+          <Button icon={<ArrowClockwise size={14} />} size="small">
+            {t('common.reset')}
+          </Button>
+        </Space>
+      </div>
+
+      <Card bordered={false} style={{ borderRadius: 8, boxShadow: '0 1px 6px 
rgba(0,0,0,0.04)' }}>
+        <Table
+          columns={columns}
+          dataSource={groupData.filter((d) => !searchText || 
d.group.includes(searchText))}
+          pagination={{
+            pageSize: 10,
+            showTotal: (total) => `${t('common.total')} ${total} Group`,
+            showSizeChanger: true,
+          }}
+          size="middle"
+        />
+      </Card>
+
+      <Modal
+        title={null}
+        open={modalVisible}
+        onCancel={() => setModalVisible(false)}
+        footer={null}
+        width={720}
+        destroyOnClose
+      >
+        <div style={{ marginBottom: 16 }}>
+          <h3 style={{ margin: 0, display: 'flex', alignItems: 'center' }}>
+            <Users size={18} style={{ marginRight: 8, color: '#1677ff' }} />
+            {selectedGroup}
+          </h3>
+        </div>
+        <Tabs
+          defaultActiveKey="overview"
+          items={[
+            {
+              key: 'overview',
+              label: t('groupMgmt.overview'),
+              children: (
+                <div>
+                  <Row gutter={16} style={{ marginBottom: 20 }}>
+                    <Col span={8}>
+                      <Card bordered={false} style={{ background: '#f6ffed' }}>
+                        <div style={{ color: '#666', fontSize: 12 }}>
+                          {t('groupMgmt.onlineInstances')}
+                        </div>
+                        <div style={{ fontSize: 24, fontWeight: 600 }}>
+                          4{' '}
+                          <Tag color="success" style={{ marginLeft: 8 }}>
+                            {t('groupMgmt.online')}
+                          </Tag>
+                        </div>
+                      </Card>
+                    </Col>
+                    <Col span={8}>
+                      <Card bordered={false} style={{ background: '#fff2f0' }}>
+                        <div style={{ color: '#666', fontSize: 12 }}>
+                          {t('groupMgmt.totalDiff')}
+                        </div>
+                        <div style={{ fontSize: 24, fontWeight: 600, color: 
'#ff4d4f' }}>1,280</div>
+                      </Card>
+                    </Col>
+                    <Col span={8}>
+                      <Card bordered={false} style={{ background: '#f0f5ff' }}>
+                        <div style={{ color: '#666', fontSize: 12 }}>
+                          {t('groupMgmt.subscribedTopics')}
+                        </div>
+                        <div style={{ fontSize: 24, fontWeight: 600 }}>3</div>
+                      </Card>
+                    </Col>
+                  </Row>
+                  <Descriptions column={2} bordered size="small">
+                    <Descriptions.Item label={t('groupMgmt.groupName')}>
+                      order-consumer-group
+                    </Descriptions.Item>
+                    <Descriptions.Item 
label={t('groupMgmt.namespace')}>default</Descriptions.Item>
+                    <Descriptions.Item label={t('groupMgmt.cluster')}>
+                      cluster-production
+                    </Descriptions.Item>
+                    <Descriptions.Item label={t('groupMgmt.consumeMode')}>
+                      <Tag color="blue">{t('groupMgmt.clustering')}</Tag>
+                    </Descriptions.Item>
+                    <Descriptions.Item 
label={t('groupMgmt.consumeType')}>PUSH</Descriptions.Item>
+                    <Descriptions.Item 
label={t('groupMgmt.consumeDelay')}>12ms</Descriptions.Item>
+                    <Descriptions.Item 
label={t('groupMgmt.maxRetry')}>16</Descriptions.Item>
+                    <Descriptions.Item label={t('groupMgmt.createdAt')}>
+                      2025-03-15 10:30:00
+                    </Descriptions.Item>
+                    <Descriptions.Item label={t('groupMgmt.subscribedTopics')} 
span={2}>
+                      ORDER_TOPIC, PAYMENT_TOPIC, INVENTORY_TOPIC
+                    </Descriptions.Item>
+                  </Descriptions>
+                  <h4 style={{ marginTop: 20, marginBottom: 12 
}}>{t('groupMgmt.subscription')}</h4>
+                  <Table
+                    columns={subscriptionColumns}
+                    dataSource={subscriptionData}
+                    pagination={false}
+                    size="small"
+                  />
+                </div>
+              ),
+            },
+            {
+              key: 'instances',
+              label: t('groupMgmt.onlineInstances'),
+              children: (
+                <Table
+                  columns={[
+                    {
+                      title: t('groupMgmt.instanceId'),
+                      dataIndex: 'instanceId',
+                      key: 'instanceId',
+                    },
+                    { title: t('common.address'), dataIndex: 'address', key: 
'address' },
+                    { title: t('brokerCluster.version'), dataIndex: 'version', 
key: 'version' },
+                    {
+                      title: t('brokerCluster.status'),
+                      dataIndex: 'status',
+                      key: 'status',
+                      render: () => <Tag 
color="success">{t('groupMgmt.online')}</Tag>,
+                    },
+                  ]}
+                  dataSource={instanceData}
+                  pagination={false}
+                  size="small"
+                />
+              ),
+            },
+            {
+              key: 'progress',
+              label: t('groupMgmt.consumeProgress'),
+              children: (
+                <Table
+                  columns={[
+                    { title: 'Topic', dataIndex: 'topic', key: 'topic' },
+                    { title: 'QueueId', dataIndex: 'queueId', key: 'queueId' },
+                    { title: 'Broker Offset', dataIndex: 'brokerOffset', key: 
'brokerOffset' },
+                    {
+                      title: 'Consumer Offset',
+                      dataIndex: 'consumerOffset',
+                      key: 'consumerOffset',
+                    },
+                    {
+                      title: 'Diff',
+                      dataIndex: 'diff',
+                      key: 'diff',
+                      render: (v: number) => (
+                        <span style={{ color: v > 100 ? '#ff4d4f' : '#52c41a', 
fontWeight: 500 }}>
+                          {v}
+                        </span>
+                      ),
+                    },
+                  ]}
+                  dataSource={progressData}
+                  pagination={false}
+                  size="small"
+                />
+              ),
+            },
+          ]}
+        />
+      </Modal>
+    </div>
+  );
+};
+
+export default GroupManagementPage;
diff --git a/web/src/pages/studio/__tests__/GroupManagement.test.tsx 
b/web/src/pages/studio/__tests__/GroupManagement.test.tsx
new file mode 100644
index 00000000..3ac9fbbe
--- /dev/null
+++ b/web/src/pages/studio/__tests__/GroupManagement.test.tsx
@@ -0,0 +1,97 @@
+/*
+ * 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 { describe, it, expect, vi, beforeAll } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import { LangProvider } from '../../../i18n/LangContext';
+import GroupManagement from '../GroupManagement';
+
+// Mock matchMedia for antd responsive components
+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(),
+    })),
+  });
+});
+
+// Mock react-router-dom
+vi.mock('react-router-dom', () => ({
+  useNavigate: () => vi.fn(),
+  useParams: () => ({}),
+}));
+
+const renderWithProviders = (ui: React.ReactElement) => {
+  return render(
+    <App>
+      <LangProvider>{ui}</LangProvider>
+    </App>,
+  );
+};
+
+describe('GroupManagement Page', () => {
+  it('should render the page title', () => {
+    renderWithProviders(<GroupManagement />);
+    expect(screen.getByText('消费组管理')).toBeInTheDocument();
+  });
+
+  it('should render search input with placeholder', () => {
+    renderWithProviders(<GroupManagement />);
+    expect(screen.getByPlaceholderText('搜索消费组')).toBeInTheDocument();
+  });
+
+  it('should render create group button', () => {
+    renderWithProviders(<GroupManagement />);
+    expect(screen.getByText('创建消费组')).toBeInTheDocument();
+  });
+
+  it('should render reset button', () => {
+    renderWithProviders(<GroupManagement />);
+    expect(screen.getByText('重置')).toBeInTheDocument();
+  });
+
+  it('should display mock consumer group data in table', () => {
+    renderWithProviders(<GroupManagement />);
+    expect(screen.getByText('order-consumer-group')).toBeInTheDocument();
+    expect(screen.getByText('payment-consumer-group')).toBeInTheDocument();
+  });
+
+  it('should render detail action buttons for each row', () => {
+    renderWithProviders(<GroupManagement />);
+    const detailButtons = screen.getAllByText('详情');
+    expect(detailButtons.length).toBeGreaterThan(0);
+  });
+
+  it('should filter groups by search text', async () => {
+    const user = userEvent.setup();
+    renderWithProviders(<GroupManagement />);
+    const searchInput = screen.getByPlaceholderText('搜索消费组');
+    await user.type(searchInput, 'order');
+    expect(screen.getByText('order-consumer-group')).toBeInTheDocument();
+    
expect(screen.queryByText('payment-consumer-group')).not.toBeInTheDocument();
+  });
+});

Reply via email to