Browse Source

feat: 增强Icon组件支持URL图标并优化用户界面显示

- 在Icon组件中新增url属性支持,可直接使用外部URL作为图标源
- URL图标拥有最高优先级,绕过所有本地图标解析机制
- 添加URL类型验证和错误处理机制,支持所有标准img标签属性
- 更新SystemZone组件集成新的URL图标功能,优化用户头像显示逻辑
- 在MeButton组件中优化样式和头像显示逻辑
- 添加用户未登录状态的SVG图标资源

改动文件:
- src/components/Icon/README.md
- src/components/Icon/index.tsx
- src/layouts/SystemZone.tsx
- src/pages/security/components/MeButton.tsx
- src/assets/Icons/base/module-security/theme-default/1x/user-not-login.svg
sw 2 days ago
parent
commit
b3ebea5d4e

+ 1 - 0
src/assets/Icons/base/module-security/theme-default/1x/user-not-login.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" id="User-Remove-Subtract--Streamline-Core"><desc>User Remove Subtract Streamline Icon: https://streamlinehq.com</desc><g id="user-remove-subtract--actions-remove-close-geometric-human-person-minus-single-up-user"><path id="Vector" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" d="M5.04 6.02C6.3931 6.02 7.49 4.9231 7.49 3.57S6.3931 1.12 5.04 1.12S2.59 2.2169 2.59 3.57S3.6869 6.02 5.04 6.02Z" stroke-width="1"></path><path id="Vector_2" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" d="M6.51 12.88H0.63V12.3487C0.6378 11.6018 0.8348 10.8689 1.2026 10.2188C1.5704 9.5686 2.097 9.0222 2.7331 8.6307C3.3693 8.2392 4.0944 8.0154 4.8405 7.98C4.9071 7.9769 4.9736 7.9752 5.04 7.9751C5.1064 7.9752 5.1729 7.9769 5.2395 7.98C5.9856 8.0154 6.7107 8.2392 7.3469 8.6307C7.7127 8.8559 8.0423 9.1322 8.3263 9.45" stroke-width="1"></path><path id="Vector_3" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" d="M8.47 11.9H13.37" stroke-width="1"></path></g></svg>

+ 143 - 5
src/components/Icon/README.md

@@ -2,16 +2,17 @@
 
 ## 概述
 
-Icon 组件现在支持产品层级功能,允许不同产品使用专属的图标集,同时保持完整的降级机制
+Icon 组件支持多种图标来源,包括本地图标、产品层级图标和外部URL图标。组件提供完整的降级机制和灵活的图标管理方式
 
 ## 层级优先级
 
 图标解析按以下优先级顺序查找:
 
-1. **Custom层** (最高优先级) - 全局用户自定义图标
-2. **Product-Custom层** - 产品内用户自定义图标
-3. **Product-Base层** - 产品基础图标
-4. **Base层** (最低优先级) - 全局基础图标,作为兜底
+1. **URL图标** (最高优先级) - 直接指定的外部URL图标
+2. **Custom层** - 全局用户自定义图标
+3. **Product-Custom层** - 产品内用户自定义图标
+4. **Product-Base层** - 产品基础图标
+5. **Base层** (最低优先级) - 全局基础图标,作为兜底
 
 ## 目录结构
 
@@ -70,6 +71,134 @@ import Icon from './components/Icon';
 <Icon module="module-common" name="icon-save" size="2x" />;
 ```
 
+### URL图标支持
+
+Icon 组件现在支持直接使用外部URL作为图标源,这是最简单直接的方式,优先级高于所有其他图标解析参数。
+
+#### 基本URL用法
+
+```tsx
+// 使用外部URL图标
+<Icon
+  url="https://example.com/icons/save-icon.png"
+  module="common"  // 仍需提供module和name作为fallback标识
+  name="save-icon"
+  alt="Save Icon"
+  size="2x"
+  widthPx={24}
+/>
+```
+
+#### 相对路径URL
+
+```tsx
+// 使用相对路径URL
+<Icon
+  url="/icons/local-icon.svg"
+  module="common"
+  name="local-icon"
+  alt="Local Icon"
+/>
+```
+
+#### 动态URL图标
+
+```tsx
+// 动态URL(例如从API获取)
+const dynamicIconUrl = userPreferences.iconUrl;
+
+<Icon
+  url={dynamicIconUrl}
+  module="user"
+  name="user-preferred-icon"
+  alt="User Icon"
+  onError={(e) => {
+    console.error('图标加载失败:', e);
+    // 可以在这里设置fallback图标
+  }}
+/>
+```
+
+#### URL图标的优势
+
+1. **简单直接**:无需复杂的图标注册机制
+2. **动态支持**:支持运行时动态切换图标
+3. **外部资源**:可以使用CDN、云存储等外部资源
+4. **用户上传**:支持用户上传的自定义图标
+5. **优先级最高**:当提供URL时,会绕过所有本地图标解析
+
+#### URL图标支持的属性
+
+URL图标支持所有标准的 `<img>` 标签属性:
+
+```tsx
+<Icon
+  url="https://example.com/icons/advanced-icon.svg"
+  module="common"
+  name="advanced-icon"
+  alt="Advanced Icon"
+  widthPx={32}
+  heightPx={32}
+  className="custom-icon-class"
+  style={{ borderRadius: '4px' }}
+  loading="lazy"
+  crossOrigin="anonymous"
+  onError={(e) => handleIconError(e)}
+  onLoad={(e) => handleIconLoad(e)}
+/>
+```
+
+#### 错误处理
+
+URL图标包含内置的错误处理机制:
+
+```tsx
+<Icon
+  url="https://example.com/icons/possibly-broken-icon.png"
+  module="common"
+  name="possibly-broken-icon"
+  onError={(e) => {
+    // 自定义错误处理逻辑
+    console.warn('图标加载失败,使用备用图标');
+    // 可以在这里设置状态来切换到备用图标
+  }}
+/>
+```
+
+#### 类型安全
+
+组件包含URL类型验证:
+
+```tsx
+// ✅ 正确的用法
+<Icon url="https://example.com/icon.png" module="common" name="icon" />
+
+// ❌ 错误的用法(会在控制台显示警告)
+<Icon url={123} module="common" name="icon" />  // url必须是字符串
+
+// ❌ 无效的URL格式(会在控制台显示警告,但仍会尝试渲染)
+<Icon url="invalid-url" module="common" name="icon" />
+```
+
+#### URL图标与本地图标的优先级
+
+当同时提供URL和其他图标参数时,优先级如下:
+
+1. **URL图标**(最高优先级)
+2. Custom层用户自定义图标
+3. Product-Custom层图标
+4. Product-Base层图标
+5. Base层图标(最低优先级)
+
+```tsx
+// 即使module和name对应的本地图标存在,也会使用URL
+<Icon
+  url="https://example.com/icons/priority-icon.svg"
+  module="common"
+  name="existing-local-icon"  // 这个本地图标会被忽略
+/>
+```
+
 ### 产品特定图标
 
 ```tsx
@@ -199,6 +328,15 @@ import TestProductLayer from './components/Icon/test-product-layer';
 
 ## 更新日志
 
+### v2.1.0 - URL图标支持
+
+- ✅ 新增 `url` 参数支持,可直接使用外部URL作为图标源
+- ✅ URL图标拥有最高优先级,绕过所有本地图标解析
+- ✅ 支持绝对URL、相对URL和动态URL
+- ✅ 添加URL类型验证和错误处理机制
+- ✅ 支持所有标准 `<img>` 标签属性
+- ✅ 完全向后兼容,现有代码无需修改
+
 ### v2.0.0 - 产品层级支持
 
 - ✅ 新增 `productId` 参数支持

+ 41 - 0
src/components/Icon/index.tsx

@@ -31,6 +31,7 @@ export interface IconProps extends React.ImgHTMLAttributes<HTMLImageElement> {
   alt?: string;
   useAntdIcon?: boolean; // 是否尝试用 @ant-design/icons 包裹 svg
   widthPx?: number; // 以像素指定大小(覆盖 class 控制)
+  url?: string; // 新增:直接指定图标URL,优先级高于其他图标解析参数
 }
 
 /**
@@ -49,8 +50,48 @@ const Icon: React.FC<IconProps> = ({
   widthPx,
   className,
   style,
+  url, // 新增:解构url属性
   ...imgProps
 }) => {
+  // 新增:优先处理URL图标
+  if (url) {
+    // 类型安全:确保url是字符串
+    if (typeof url !== 'string') {
+      console.warn('Icon组件: url属性必须是字符串类型');
+      return null;
+    }
+
+    // 基本URL格式验证
+    try {
+      new URL(url); // 这会验证URL格式,但不要求必须是http/https
+    } catch (e) {
+      // 如果不是有效的URL,可能是相对路径,这是允许的
+      if (!url.startsWith('/') && !url.startsWith('./') && !url.startsWith('../')) {
+        console.warn('Icon组件: url属性格式无效', url);
+      }
+    }
+
+    return (
+      <img
+        src={url}
+        alt={alt ?? name}
+        width={widthPx ?? undefined}
+        height={widthPx ?? undefined}
+        className={className}
+        style={style}
+        // 添加错误处理
+        onError={(e) => {
+          console.warn('Icon组件: URL图标加载失败', url);
+          // 调用用户提供的onError回调(如果存在)
+          if (imgProps.onError) {
+            imgProps.onError(e);
+          }
+        }}
+        {...imgProps}
+      />
+    );
+  }
+
   const resolved: ResolvedIcon | undefined = resolveIcon({
     userId,
     productId,

+ 51 - 7
src/layouts/SystemZone.tsx

@@ -10,6 +10,7 @@ import ExitModal from '@/components/ExitModal';
 import LanguageSettingModal from '@/components/LanguageSettingModal';
 import { showNotImplemented } from '@/utils/notificationHelper';
 import { FormattedMessage } from 'react-intl';
+import { UserOutlined } from '@ant-design/icons';
 
 interface SystemZoneProps {
   onMenuClick?: (key: string) => void;
@@ -57,10 +58,16 @@ const SystemZone = forwardRef<HTMLDivElement, SystemZoneProps>(
           boxShadow: '0 -2px 5px rgba(0,0,0,0.1)',
         }}
       >
+        <style>{`
+          .system-zone-space .ant-space-item {
+            width: 100%;
+          }
+        `}</style>
         <Space
           direction="vertical"
           align="start"
           style={{ width: '100%', paddingLeft: 20 }}
+          className='system-zone-space'
         >
           <IconButton
             icon={
@@ -77,6 +84,7 @@ const SystemZone = forwardRef<HTMLDivElement, SystemZoneProps>(
             iconPlace="left"
             iconSize={32} // 和size 2x 保持一致
             type="primary"
+            block
             style={{ padding: '4px 16px' }}
             onClick={handleLanguageClick}
           >
@@ -86,14 +94,49 @@ const SystemZone = forwardRef<HTMLDivElement, SystemZoneProps>(
             />
           </IconButton>
 
-          <MeButton
-            size="large"
-            isLogin={login}
-            avatarUrl={avatarUrl || undefined}
-            username={login ? username : '未登录'}
-            //onClick={() => onMenuClick?.('me')}
+          <IconButton
+            icon={
+              login ? (
+                avatarUrl ? (
+                  <Icon
+                    module="module-common"
+                    name="btn_3DCam_AIView"
+                    userId="user-A"
+                    theme="default"
+                    size="2x"
+                    state="normal"
+                    url={avatarUrl}
+                  />
+                ) : (
+                  <Icon
+                    module="module-security"
+                    name="user-not-login"
+                    userId="user-A"
+                    theme="default"
+                    size="2x"
+                    state="normal"
+                  />
+                )
+              ) : (
+                <Icon
+                  module="module-common"
+                  name="user-not-login"
+                  userId="user-A"
+                  theme="default"
+                  size="2x"
+                  state="normal"
+                />
+              )
+            }
+            iconPlace="left"
+            iconSize={32}
+            type="primary"
+            block
+            style={{ padding: '4px 16px' }}
             onClick={() => showNotImplemented('我的')}
-          />
+          >
+            {login ? username : '未登录'}
+          </IconButton>
 
           <IconButton
             data-testid="exit-button"
@@ -110,6 +153,7 @@ const SystemZone = forwardRef<HTMLDivElement, SystemZoneProps>(
             iconPlace="left"
             iconSize={32}
             type="primary"
+            block
             style={{ padding: '4px 16px' }}
             onClick={handleExitClick}
           >

+ 7 - 3
src/pages/security/components/MeButton.tsx

@@ -9,6 +9,7 @@ interface MeButtonProps extends AvatarProps {
   icon?: React.ReactNode;
   'data-testid'?: string;
   disabled?: boolean;
+  block?: boolean;
 }
 /**
  * 没有传递isLogin时,当作普通按钮使用
@@ -28,35 +29,38 @@ const MeButton: React.FC<MeButtonProps> = ({
   username,
   icon,
   disabled,
+  block,
   ...props
 }) => {
   return (
     <Button
       onClick={onClick}
+      block={block}
       style={{
         display: 'flex',
         alignItems: 'center',
+        justifyContent: 'flex-start',
         // border: 'none',
         background: disabled ? 'transparent' : 'gray',
         padding: 0,
         cursor: 'pointer',
+        width: block ? '100%' : 'auto',
       }}
       className={props.className}
       data-testid={props['data-testid']}
-      disabled={disabled} // 如果传递了disabled属性则可能禁用按钮
+      disabled={disabled} // 如果传递了disabled属性,则可能禁用按钮
     >
       {isLogin ? (
         <Avatar
           src={avatarUrl}
           icon={!!avatarUrl === false ? icon || <UserOutlined /> : undefined} // 如果avatarUrl为false,则使用icon或默认UserOutlined图标
           size={props.size}
-          style={{ marginRight: username ? 8 : 0 }}
+          
         />
       ) : (
         <Avatar
           icon={icon || <UserOutlined />}
           size={props.size}
-          style={{ marginRight: username ? 8 : 0 }}
         />
       )}
       {username && (