From 9f6212d61eba53eb4da504876f5a01b93a80fc1c Mon Sep 17 00:00:00 2001
From: Tevin <tingquanren@163.com>
Date: Thu, 09 Apr 2026 18:19:50 +0800
Subject: [PATCH] docs(skill): 将 playwright-patterns.md 翻译为中文

---
 src/framework/sideMenu/CSideMenu.tsx |  227 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 227 insertions(+), 0 deletions(-)

diff --git a/src/framework/sideMenu/CSideMenu.tsx b/src/framework/sideMenu/CSideMenu.tsx
new file mode 100644
index 0000000..c14a47b
--- /dev/null
+++ b/src/framework/sideMenu/CSideMenu.tsx
@@ -0,0 +1,227 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import { Menu, Layout } from 'antd';
+import type { MenuProps } from 'antd';
+import {
+  PieChartOutlined,
+  SettingOutlined,
+  FolderOutlined,
+  FileOutlined,
+} from '@ant-design/icons';
+import type { CSideMenuProps, MenuItem } from './types';
+import './cSideMenu.scss';
+
+/** 图标类型映射 */
+const iconMap: Record<string, React.ReactNode> = {
+  chart: <PieChartOutlined />,
+  setting: <SettingOutlined />,
+  folder: <FolderOutlined />,
+  file: <FileOutlined />,
+};
+
+/** 默认图标 */
+const DefaultIcon = <FileOutlined />;
+
+/** 转换键值类型 */
+function normalizeKey(key: string | number): string {
+  return String(key);
+}
+
+/** antd Menu 项类型 */
+interface AntdMenuItem {
+  key: string;
+  label: React.ReactNode;
+  icon?: React.ReactNode;
+  children?: AntdMenuItem[];
+}
+
+/**
+ * 将 MenuTree 转换为 antd Menu items 格式
+ */
+function convertTreeToItems(
+  tree: MenuItem[],
+  panesOnShelf: Array<{ key: string }> = []
+): AntdMenuItem[] {
+  return tree.map((node) => {
+    const icon = node.type ? iconMap[node.type] || DefaultIcon : DefaultIcon;
+    const isOpened = panesOnShelf.some((p) => normalizeKey(p.key) === normalizeKey(node.key));
+    const item: AntdMenuItem = {
+      key: normalizeKey(node.key),
+      label: isOpened ? (
+        <span>
+          {node.label}
+          <span className="c-side-menu__opened-indicator"> (已打开)</span>
+        </span>
+      ) : (
+        node.label
+      ),
+      icon,
+    };
+    if (node.children && node.children.length > 0) {
+      item.children = convertTreeToItems(node.children, panesOnShelf);
+    }
+    return item;
+  });
+}
+
+/** 递归查找叶子节点 */
+function findLeafKeys(items: AntdMenuItem[], selectedKey: string): string[] {
+  const result: string[] = [];
+  const findKey = (items: AntdMenuItem[], key: string): boolean => {
+    for (const item of items || []) {
+      if (item.key === key) {
+        if (!item.children || item.children.length === 0) {
+          result.push(key);
+        }
+        return true;
+      }
+      if (item.children && findKey(item.children, key)) {
+        return true;
+      }
+    }
+    return false;
+  };
+  findKey(items, selectedKey);
+  return result;
+}
+
+export const CSideMenu: React.FC<CSideMenuProps> = (props) => {
+  const {
+    title,
+    tree,
+    collapsed,
+    curActivePaneKey,
+    panesOnShelf = [],
+    onClickMenuItem,
+    onSetMenuCollapse,
+  } = props;
+
+  const [isMobile, setIsMobile] = useState(false);
+  const [openKeys, setOpenKeys] = useState<string[]>([]);
+
+  // 监听响应式断点 - 与 antd Layout.Sider breakpoint="lg" 一致 (992px)
+  useEffect(() => {
+    const checkMobile = () => {
+      const width = window.innerWidth;
+      const screenWidth = window.screen.width;
+      // 只有当视口和屏幕都比较小时才认为是移动端
+      setIsMobile(width <= 992 && screenWidth <= 1024);
+    };
+
+    const mediaQuery = window.matchMedia('(max-width: 992px)');
+    checkMobile(); // 初始检测
+
+    const handler = () => {
+      checkMobile();
+    };
+
+    mediaQuery.addEventListener('change', handler);
+    return () => mediaQuery.removeEventListener('change', handler);
+  }, []);
+
+  // 菜单项
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const items: any[] = tree ? convertTreeToItems([tree], panesOnShelf) : [];
+
+  // 选中的键
+  const selectedKeys = curActivePaneKey ? findLeafKeys(items, normalizeKey(curActivePaneKey)) : [];
+
+  // 全局手风琴逻辑 - 当展开一个新分支时,关闭之前展开的分支
+  const handleOpenChange: MenuProps['onOpenChange'] = useCallback(
+    (keys: string[]) => {
+      const latestKey = keys[keys.length - 1];
+      if (latestKey) {
+        // 只保留最新展开的键
+        setOpenKeys([latestKey]);
+      } else {
+        setOpenKeys([]);
+      }
+    },
+    []
+  );
+
+  // 叶子项点击
+  const handleClick: MenuProps['onClick'] = useCallback(
+    ({ key }: { key: string }) => {
+      if (!tree) return;
+
+      // 递归查找被点击的菜单项
+      const findItem = (nodes: MenuItem[], k: string): MenuItem | null => {
+        for (const node of nodes) {
+          if (normalizeKey(node.key) === k) return node;
+          if (node.children) {
+            const found = findItem(node.children, k);
+            if (found) return found;
+          }
+        }
+        return null;
+      };
+
+      const clickedItem = findItem([tree], key);
+      if (clickedItem) {
+        // 移动端:先收起,延迟后再导航
+        if (isMobile) {
+          setTimeout(() => {
+            onClickMenuItem(clickedItem);
+          }, 300);
+        } else {
+          onClickMenuItem(clickedItem);
+        }
+      }
+    },
+    [tree, isMobile, onClickMenuItem]
+  );
+
+  // 空树处理
+  if (!tree) {
+    return (
+      <div className="c-side-menu">
+        <div className="c-side-menu__header">
+          <h1 className="c-side-menu__title">{title}</h1>
+        </div>
+        <div className="c-side-menu__empty">暂无菜单</div>
+      </div>
+    );
+  }
+
+  // 移动端遮罩点击关闭
+  const handleOverlayClick = () => {
+    onSetMenuCollapse(true);
+  };
+
+  const menuContent = (
+    <>
+      <div className="c-side-menu__header">
+        <h1 className="c-side-menu__title">{title}</h1>
+      </div>
+      <Menu
+        className="c-side-menu__menu"
+        mode="inline"
+        inlineCollapsed={isMobile ? false : collapsed}
+        items={items}
+        selectedKeys={selectedKeys}
+        openKeys={openKeys}
+        onOpenChange={handleOpenChange}
+        onClick={handleClick}
+      />
+    </>
+  );
+
+  // PC 端 - 禁用 breakpoint,响应式由外部控制
+  if (!isMobile) {
+    return (
+      <Layout.Sider className="c-side-menu" collapsedWidth={0} width={200}>
+        {menuContent}
+      </Layout.Sider>
+    );
+  }
+
+  // 移动端 - 使用外部传入的 collapsed 控制显示
+  return (
+    <>
+      <div className={`c-side-menu c-side-menu--mobile ${!collapsed ? 'c-side-menu--open' : ''}`}>
+        {menuContent}
+      </div>
+      {!collapsed && <div className="c-side-menu__overlay" onClick={handleOverlayClick} />}
+    </>
+  );
+};

--
Gitblit v1.9.1