From 01e1c6c7e92466147d32b9da61a85febad915be1 Mon Sep 17 00:00:00 2001 From: Kecheng Sha <101926927+o0Shark0o@users.noreply.github.com> Date: Wed, 31 Dec 2025 10:54:34 +0800 Subject: [PATCH] =?UTF-8?q?polish(operator=20cards):=20improve=20icon=20co?= =?UTF-8?q?lor=20distinction=20and=20subtle=20UI=20=E2=80=A6=20(#217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit polish(operator cards): improve icon color distinction and subtle UI details --- frontend/src/components/CardView.tsx | 598 +++++++++--------- frontend/src/components/DetailHeader.tsx | 295 ++++----- .../OperatorMarket/Home/components/List.tsx | 191 +++--- .../pages/OperatorMarket/operator.const.tsx | 71 ++- .../pages/OperatorMarket/operator.model.ts | 125 ++-- 5 files changed, 679 insertions(+), 601 deletions(-) diff --git a/frontend/src/components/CardView.tsx b/frontend/src/components/CardView.tsx index 04301cb..9e044aa 100644 --- a/frontend/src/components/CardView.tsx +++ b/frontend/src/components/CardView.tsx @@ -1,292 +1,306 @@ -import React, { useState, useEffect, useRef } from "react"; -import { Tag, Pagination, Tooltip, Empty, Popover, Spin } from "antd"; -import { ClockCircleOutlined, StarFilled } from "@ant-design/icons"; -import type { ItemType } from "antd/es/menu/interface"; -import { formatDateTime } from "@/utils/unit"; -import ActionDropdown from "./ActionDropdown"; -import { Database } from "lucide-react"; - -interface BaseCardDataType { - id: string | number; - name: string; - type: string; - icon?: React.JSX.Element; - iconColor?: string; - status: { - label: string; - icon?: React.JSX.Element; - color?: string; - } | null; - description: string; - tags?: string[]; - statistics?: { label: string; value: string | number }[]; - updatedAt?: string; -} - -interface CardViewProps { - data: T[]; - pagination: { - [key: string]: any; - current: number; - pageSize: number; - total: number; - }; - operations: - | { - key: string; - label: string; - danger?: boolean; - icon?: React.JSX.Element; - onClick?: (item: T) => void; - }[] - | ((item: T) => ItemType[]); - loading?: boolean; - onView?: (item: T) => void; - onFavorite?: (item: T) => void; - isFavorite?: (item: T) => boolean; -} - -// 标签渲染组件 -const TagsRenderer = ({ tags }: { tags?: any[] }) => { - const [visibleTags, setVisibleTags] = useState([]); - const [hiddenTags, setHiddenTags] = useState([]); - const containerRef = useRef(null); - - useEffect(() => { - if (!tags || tags.length === 0) return; - - const calculateVisibleTags = () => { - if (!containerRef.current) return; - - const containerWidth = containerRef.current.offsetWidth; - const tempDiv = document.createElement("div"); - tempDiv.style.visibility = "hidden"; - tempDiv.style.position = "absolute"; - tempDiv.style.top = "-9999px"; - tempDiv.className = "flex flex-wrap gap-1"; - document.body.appendChild(tempDiv); - - let totalWidth = 0; - let visibleCount = 0; - const tagElements: HTMLElement[] = []; - - // 为每个tag创建临时元素来测量宽度 - tags.forEach((tag, index) => { - const tagElement = document.createElement("span"); - tagElement.className = "ant-tag ant-tag-default"; - tagElement.style.margin = "2px"; - tagElement.textContent = typeof tag === "string" ? tag : tag.name; - tempDiv.appendChild(tagElement); - tagElements.push(tagElement); - - const tagWidth = tagElement.offsetWidth + 4; // 加上gap的宽度 - - // 如果不是最后一个标签,需要预留+n标签的空间 - const plusTagWidth = index < tags.length - 1 ? 35 : 0; // +n标签大约35px宽度 - - if (totalWidth + tagWidth + plusTagWidth <= containerWidth) { - totalWidth += tagWidth; - visibleCount++; - } else { - // 如果当前标签放不下,且已经有可见标签,则停止 - if (visibleCount > 0) return; - // 如果是第一个标签就放不下,至少显示一个 - if (index === 0) { - totalWidth += tagWidth; - visibleCount = 1; - } - } - }); - - document.body.removeChild(tempDiv); - - setVisibleTags(tags.slice(0, visibleCount)); - setHiddenTags(tags.slice(visibleCount)); - }; - - // 延迟执行以确保DOM已渲染 - const timer = setTimeout(calculateVisibleTags, 0); - - // 监听窗口大小变化 - const handleResize = () => { - calculateVisibleTags(); - }; - - window.addEventListener("resize", handleResize); - - return () => { - clearTimeout(timer); - window.removeEventListener("resize", handleResize); - }; - }, [tags]); - - if (!tags || tags.length === 0) return null; - - const popoverContent = ( -
-
- {hiddenTags.map((tag, index) => ( - {typeof tag === "string" ? tag : tag.name} - ))} -
-
- ); - - return ( -
- {visibleTags.map((tag, index) => ( - {typeof tag === "string" ? tag : tag.name} - ))} - {hiddenTags.length > 0 && ( - - - +{hiddenTags.length} - - - )} -
- ); -}; - -function CardView(props: CardViewProps) { - const { - data, - pagination, - operations, - loading, - onView, - onFavorite, - isFavorite, - } = props; - - if (data.length === 0) { - return ( -
- -
- ); - } - - const ops = (item) => - typeof operations === "function" ? operations(item) : operations; - - return ( -
-
- {data.map((item) => ( -
-
-
onView?.(item)} - style={{ cursor: onView ? "pointer" : "default" }} - > - {/* Header */} -
-
- {item?.icon && ( -
-
{item?.icon}
-
- )} -
-
-

- {item?.name} -

- {item?.status && ( - -
- {item?.status?.icon && ( - {item?.status?.icon} - )} - {item?.status?.label} -
-
- )} -
-
-
- {onFavorite && ( - onFavorite?.(item)} - /> - )} -
- -
- {/* Tags */} - - - {/* Description */} -

- - {item?.description} - -

- - {/* Statistics */} -
- {item?.statistics?.map((stat, idx) => ( -
-
- {stat?.label}: -
-
- {stat?.value} -
-
- ))} -
-
-
- - {/* Actions */} -
-
-
- {" "} - {formatDateTime(item?.updatedAt)} -
-
- {operations && ( - { - const operation = ops(item).find((op) => op.key === key); - if (operation?.onClick) { - operation.onClick(item); - } - }} - /> - )} -
-
-
- ))} -
-
- -
-
- ); -} - -export default CardView; +import React, { useState, useEffect, useRef } from "react"; +import { Tag, Pagination, Tooltip, Empty, Popover, Spin } from "antd"; +import { ClockCircleOutlined, StarFilled } from "@ant-design/icons"; +import type { ItemType } from "antd/es/menu/interface"; +import { formatDateTime } from "@/utils/unit"; +import ActionDropdown from "./ActionDropdown"; +import { Database } from "lucide-react"; + +interface BaseCardDataType { + id: string | number; + name: string; + type: string; + icon?: React.JSX.Element; + iconColor?: string; + status: { + label: string; + icon?: React.JSX.Element; + color?: string; + } | null; + description: string; + tags?: string[]; + statistics?: { label: string; value: string | number }[]; + updatedAt?: string; +} + +interface CardViewProps { + data: T[]; + pagination: { + [key: string]: any; + current: number; + pageSize: number; + total: number; + }; + operations: + | { + key: string; + label: string; + danger?: boolean; + icon?: React.JSX.Element; + onClick?: (item: T) => void; + }[] + | ((item: T) => ItemType[]); + loading?: boolean; + onView?: (item: T) => void; + onFavorite?: (item: T) => void; + isFavorite?: (item: T) => boolean; +} + +// 标签渲染组件 +const TagsRenderer = ({ tags }: { tags?: any[] }) => { + const [visibleTags, setVisibleTags] = useState([]); + const [hiddenTags, setHiddenTags] = useState([]); + const containerRef = useRef(null); + + useEffect(() => { + if (!tags || tags.length === 0) return; + + const calculateVisibleTags = () => { + if (!containerRef.current) return; + + const containerWidth = containerRef.current.offsetWidth; + const tempDiv = document.createElement("div"); + tempDiv.style.visibility = "hidden"; + tempDiv.style.position = "absolute"; + tempDiv.style.top = "-9999px"; + tempDiv.className = "flex flex-wrap gap-1"; + document.body.appendChild(tempDiv); + + let totalWidth = 0; + let visibleCount = 0; + const tagElements: HTMLElement[] = []; + + // 为每个tag创建临时元素来测量宽度 + tags.forEach((tag, index) => { + const tagElement = document.createElement("span"); + tagElement.className = "ant-tag ant-tag-default"; + tagElement.style.margin = "2px"; + tagElement.textContent = typeof tag === "string" ? tag : tag.name; + tempDiv.appendChild(tagElement); + tagElements.push(tagElement); + + const tagWidth = tagElement.offsetWidth + 4; // 加上gap的宽度 + + // 如果不是最后一个标签,需要预留+n标签的空间 + const plusTagWidth = index < tags.length - 1 ? 35 : 0; // +n标签大约35px宽度 + + if (totalWidth + tagWidth + plusTagWidth <= containerWidth) { + totalWidth += tagWidth; + visibleCount++; + } else { + // 如果当前标签放不下,且已经有可见标签,则停止 + if (visibleCount > 0) return; + // 如果是第一个标签就放不下,至少显示一个 + if (index === 0) { + totalWidth += tagWidth; + visibleCount = 1; + } + } + }); + + document.body.removeChild(tempDiv); + + setVisibleTags(tags.slice(0, visibleCount)); + setHiddenTags(tags.slice(visibleCount)); + }; + + // 延迟执行以确保DOM已渲染 + const timer = setTimeout(calculateVisibleTags, 0); + + // 监听窗口大小变化 + const handleResize = () => { + calculateVisibleTags(); + }; + + window.addEventListener("resize", handleResize); + + return () => { + clearTimeout(timer); + window.removeEventListener("resize", handleResize); + }; + }, [tags]); + + if (!tags || tags.length === 0) return null; + + const popoverContent = ( +
+
+ {hiddenTags.map((tag, index) => ( + {typeof tag === "string" ? tag : tag.name} + ))} +
+
+ ); + + return ( +
+ {visibleTags.map((tag, index) => ( + {typeof tag === "string" ? tag : tag.name} + ))} + {hiddenTags.length > 0 && ( + + + +{hiddenTags.length} + + + )} +
+ ); +}; + +function CardView(props: CardViewProps) { + const { + data, + pagination, + operations, + loading, + onView, + onFavorite, + isFavorite, + } = props; + + if (data.length === 0) { + return ( +
+ +
+ ); + } + + const ops = (item) => + typeof operations === "function" ? operations(item) : operations; + + return ( +
+
+ {data.map((item) => ( +
+
+
onView?.(item)} + style={{ cursor: onView ? "pointer" : "default" }} + > + {/* Header */} +
+
+ {item?.icon && ( +
+
{item?.icon}
+
+ )} +
+
+

+ {item?.name} +

+ {item?.status && ( + +
+ {item?.status?.icon && ( + {item?.status?.icon} + )} + {item?.status?.label} +
+
+ )} +
+
+
+ {onFavorite && ( + onFavorite?.(item)} + /> + )} +
+ +
+ {/* Tags */} + + + {/* Description */} +

+ + {item?.description} + +

+ + {/* Statistics */} +
+ {item?.statistics?.map((stat, idx) => ( +
+
+ {stat?.label}: +
+
+ {stat?.value} +
+
+ ))} +
+
+
+ + {/* Divider & Actions */} +
+
+
+
+ {" "} + {formatDateTime(item?.updatedAt)} +
+
+ {operations && ( + { + const operation = ops(item).find((op) => op.key === key); + if (operation?.onClick) { + operation.onClick(item); + } + }} + /> + )} +
+
+
+ ))} +
+
+ +
+
+ ); +} + +export default CardView; diff --git a/frontend/src/components/DetailHeader.tsx b/frontend/src/components/DetailHeader.tsx index 27b5965..5164852 100644 --- a/frontend/src/components/DetailHeader.tsx +++ b/frontend/src/components/DetailHeader.tsx @@ -1,145 +1,150 @@ -import React from "react"; -import { Database } from "lucide-react"; -import { Card, Button, Tag, Tooltip, Popconfirm } from "antd"; -import type { ItemType } from "antd/es/menu/interface"; -import AddTagPopover from "./AddTagPopover"; -import ActionDropdown from "./ActionDropdown"; - -interface StatisticItem { - icon: React.ReactNode; - label: string; - value: string | number; -} - -interface OperationItem { - key: string; - label: string; - icon?: React.ReactNode; - isDropdown?: boolean; - items?: ItemType[]; - onMenuClick?: (key: string) => void; - onClick?: () => void; - danger?: boolean; -} - -interface TagConfig { - showAdd: boolean; - tags: { id: number; name: string; color: string }[]; - onFetchTags?: () => Promise<{ - data: { id: number; name: string; color: string }[]; - }>; - onAddTag?: (tag: { id: number; name: string; color: string }) => void; - onCreateAndTag?: (tagName: string) => void; -} -interface DetailHeaderProps { - data: T; - statistics: StatisticItem[]; - operations: OperationItem[]; - tagConfig?: TagConfig; -} - -function DetailHeader({ - data = {} as T, - statistics, - operations, - tagConfig, -}: DetailHeaderProps): React.ReactNode { - return ( - -
-
-
- {
{data?.icon}
|| ( - - )} -
-
-
-

{data?.name}

- {data?.status && ( - -
- {data.status?.icon && {data.status?.icon}} - {data.status?.label} -
-
- )} -
- {data?.tags && ( -
- {data?.tags?.map((tag) => ( - - {tag.name} - - ))} - {tagConfig?.showAdd && ( - - )} -
- )} -

{data?.description}

-
- {statistics.map((stat) => ( -
- {stat.icon} - {stat.value} -
- ))} -
-
-
-
- {operations.map((op) => { - if (op.isDropdown) { - return ( - - ); - } - if (op.confirm) { - return ( - - { - if (op.onClick) { - op.onClick() - } else { - op?.confirm?.onConfirm?.(); - } - }} - okType={op.danger ? "danger" : "primary"} - overlayStyle={{ zIndex: 9999 }} - > -
-
-
- ); -} - -export default DetailHeader; +import React from "react"; +import { Database } from "lucide-react"; +import { Card, Button, Tag, Tooltip, Popconfirm } from "antd"; +import type { ItemType } from "antd/es/menu/interface"; +import AddTagPopover from "./AddTagPopover"; +import ActionDropdown from "./ActionDropdown"; + +interface StatisticItem { + icon: React.ReactNode; + label: string; + value: string | number; +} + +interface OperationItem { + key: string; + label: string; + icon?: React.ReactNode; + isDropdown?: boolean; + items?: ItemType[]; + onMenuClick?: (key: string) => void; + onClick?: () => void; + danger?: boolean; +} + +interface TagConfig { + showAdd: boolean; + tags: { id: number; name: string; color: string }[]; + onFetchTags?: () => Promise<{ + data: { id: number; name: string; color: string }[]; + }>; + onAddTag?: (tag: { id: number; name: string; color: string }) => void; + onCreateAndTag?: (tagName: string) => void; +} +interface DetailHeaderProps { + data: T; + statistics: StatisticItem[]; + operations: OperationItem[]; + tagConfig?: TagConfig; +} + +function DetailHeader({ + data = {} as T, + statistics, + operations, + tagConfig, +}: DetailHeaderProps): React.ReactNode { + return ( + +
+
+
+ {
{(data as any)?.icon}
|| ( + + )} +
+
+
+

{data?.name}

+ {data?.status && ( + +
+ {data.status?.icon && {data.status?.icon}} + {data.status?.label} +
+
+ )} +
+ {data?.tags && ( +
+ {data?.tags?.map((tag) => ( + + {tag.name} + + ))} + {tagConfig?.showAdd && ( + + )} +
+ )} +

{data?.description}

+
+ {statistics.map((stat) => ( +
+ {stat.icon} + {stat.value} +
+ ))} +
+
+
+
+ {operations.map((op) => { + if (op.isDropdown) { + return ( + + ); + } + if (op.confirm) { + return ( + + { + if (op.onClick) { + op.onClick() + } else { + op?.confirm?.onConfirm?.(); + } + }} + okType={op.danger ? "danger" : "primary"} + overlayStyle={{ zIndex: 9999 }} + > +
+
+
+ ); +} + +export default DetailHeader; diff --git a/frontend/src/pages/OperatorMarket/Home/components/List.tsx b/frontend/src/pages/OperatorMarket/Home/components/List.tsx index e0d4f3d..d981981 100644 --- a/frontend/src/pages/OperatorMarket/Home/components/List.tsx +++ b/frontend/src/pages/OperatorMarket/Home/components/List.tsx @@ -1,90 +1,101 @@ -import { Button, List, Tag } from "antd"; -import { useNavigate } from "react-router"; -import { Operator } from "../../operator.model"; - -export function ListView({ operators = [], pagination, operations }) { - const navigate = useNavigate(); - - const handleViewOperator = (operator: Operator) => { - navigate(`/data/operator-market/plugin-detail/${operator.id}`); - }; - - return ( - ( - handleToggleFavorite(operator.id)} - // className={ - // favoriteOperators.has(operator.id) - // ? "text-yellow-500 hover:text-yellow-600" - // : "text-gray-400 hover:text-yellow-500" - // } - // icon={ - // handleToggleFavorite(operator.id)} - // /> - // } - // title="收藏" - // />, - ...operations.map((operation) => ( -
- } - title={ -
- handleViewOperator(operator)} - > - {operator.name} - - v{operator.version} -
- } - description={ -
-
{operator.description}
- {/*
- 作者: {operator.author} - 类型: {operator.type} - 框架: {operator.framework} - 使用次数: {operator?.usage?.toLocaleString()} -
*/} -
- } - /> - - )} - /> - ); -} +import { Button, List, Tag } from "antd"; +import { useNavigate } from "react-router"; +import { Operator } from "../../operator.model"; + +export function ListView({ operators = [], pagination, operations }) { + const navigate = useNavigate(); + + const handleViewOperator = (operator: Operator) => { + navigate(`/data/operator-market/plugin-detail/${operator.id}`); + }; + + return ( + ( + handleToggleFavorite(operator.id)} + // className={ + // favoriteOperators.has(operator.id) + // ? "text-yellow-500 hover:text-yellow-600" + // : "text-gray-400 hover:text-yellow-500" + // } + // icon={ + // handleToggleFavorite(operator.id)} + // /> + // } + // title="收藏" + // />, + ...operations.map((operation) => ( +