polish(operator cards): improve icon color distinction and subtle UI … (#217)

polish(operator cards): improve icon color distinction and subtle UI details
This commit is contained in:
Kecheng Sha
2025-12-31 10:54:34 +08:00
committed by GitHub
parent f183b9f2f3
commit 01e1c6c7e9
5 changed files with 679 additions and 601 deletions

View File

@@ -1,292 +1,306 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Tag, Pagination, Tooltip, Empty, Popover, Spin } from "antd"; import { Tag, Pagination, Tooltip, Empty, Popover, Spin } from "antd";
import { ClockCircleOutlined, StarFilled } from "@ant-design/icons"; import { ClockCircleOutlined, StarFilled } from "@ant-design/icons";
import type { ItemType } from "antd/es/menu/interface"; import type { ItemType } from "antd/es/menu/interface";
import { formatDateTime } from "@/utils/unit"; import { formatDateTime } from "@/utils/unit";
import ActionDropdown from "./ActionDropdown"; import ActionDropdown from "./ActionDropdown";
import { Database } from "lucide-react"; import { Database } from "lucide-react";
interface BaseCardDataType { interface BaseCardDataType {
id: string | number; id: string | number;
name: string; name: string;
type: string; type: string;
icon?: React.JSX.Element; icon?: React.JSX.Element;
iconColor?: string; iconColor?: string;
status: { status: {
label: string; label: string;
icon?: React.JSX.Element; icon?: React.JSX.Element;
color?: string; color?: string;
} | null; } | null;
description: string; description: string;
tags?: string[]; tags?: string[];
statistics?: { label: string; value: string | number }[]; statistics?: { label: string; value: string | number }[];
updatedAt?: string; updatedAt?: string;
} }
interface CardViewProps<T> { interface CardViewProps<T> {
data: T[]; data: T[];
pagination: { pagination: {
[key: string]: any; [key: string]: any;
current: number; current: number;
pageSize: number; pageSize: number;
total: number; total: number;
}; };
operations: operations:
| { | {
key: string; key: string;
label: string; label: string;
danger?: boolean; danger?: boolean;
icon?: React.JSX.Element; icon?: React.JSX.Element;
onClick?: (item: T) => void; onClick?: (item: T) => void;
}[] }[]
| ((item: T) => ItemType[]); | ((item: T) => ItemType[]);
loading?: boolean; loading?: boolean;
onView?: (item: T) => void; onView?: (item: T) => void;
onFavorite?: (item: T) => void; onFavorite?: (item: T) => void;
isFavorite?: (item: T) => boolean; isFavorite?: (item: T) => boolean;
} }
// 标签渲染组件 // 标签渲染组件
const TagsRenderer = ({ tags }: { tags?: any[] }) => { const TagsRenderer = ({ tags }: { tags?: any[] }) => {
const [visibleTags, setVisibleTags] = useState<any[]>([]); const [visibleTags, setVisibleTags] = useState<any[]>([]);
const [hiddenTags, setHiddenTags] = useState<any[]>([]); const [hiddenTags, setHiddenTags] = useState<any[]>([]);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!tags || tags.length === 0) return; if (!tags || tags.length === 0) return;
const calculateVisibleTags = () => { const calculateVisibleTags = () => {
if (!containerRef.current) return; if (!containerRef.current) return;
const containerWidth = containerRef.current.offsetWidth; const containerWidth = containerRef.current.offsetWidth;
const tempDiv = document.createElement("div"); const tempDiv = document.createElement("div");
tempDiv.style.visibility = "hidden"; tempDiv.style.visibility = "hidden";
tempDiv.style.position = "absolute"; tempDiv.style.position = "absolute";
tempDiv.style.top = "-9999px"; tempDiv.style.top = "-9999px";
tempDiv.className = "flex flex-wrap gap-1"; tempDiv.className = "flex flex-wrap gap-1";
document.body.appendChild(tempDiv); document.body.appendChild(tempDiv);
let totalWidth = 0; let totalWidth = 0;
let visibleCount = 0; let visibleCount = 0;
const tagElements: HTMLElement[] = []; const tagElements: HTMLElement[] = [];
// 为每个tag创建临时元素来测量宽度 // 为每个tag创建临时元素来测量宽度
tags.forEach((tag, index) => { tags.forEach((tag, index) => {
const tagElement = document.createElement("span"); const tagElement = document.createElement("span");
tagElement.className = "ant-tag ant-tag-default"; tagElement.className = "ant-tag ant-tag-default";
tagElement.style.margin = "2px"; tagElement.style.margin = "2px";
tagElement.textContent = typeof tag === "string" ? tag : tag.name; tagElement.textContent = typeof tag === "string" ? tag : tag.name;
tempDiv.appendChild(tagElement); tempDiv.appendChild(tagElement);
tagElements.push(tagElement); tagElements.push(tagElement);
const tagWidth = tagElement.offsetWidth + 4; // 加上gap的宽度 const tagWidth = tagElement.offsetWidth + 4; // 加上gap的宽度
// 如果不是最后一个标签,需要预留+n标签的空间 // 如果不是最后一个标签,需要预留+n标签的空间
const plusTagWidth = index < tags.length - 1 ? 35 : 0; // +n标签大约35px宽度 const plusTagWidth = index < tags.length - 1 ? 35 : 0; // +n标签大约35px宽度
if (totalWidth + tagWidth + plusTagWidth <= containerWidth) { if (totalWidth + tagWidth + plusTagWidth <= containerWidth) {
totalWidth += tagWidth; totalWidth += tagWidth;
visibleCount++; visibleCount++;
} else { } else {
// 如果当前标签放不下,且已经有可见标签,则停止 // 如果当前标签放不下,且已经有可见标签,则停止
if (visibleCount > 0) return; if (visibleCount > 0) return;
// 如果是第一个标签就放不下,至少显示一个 // 如果是第一个标签就放不下,至少显示一个
if (index === 0) { if (index === 0) {
totalWidth += tagWidth; totalWidth += tagWidth;
visibleCount = 1; visibleCount = 1;
} }
} }
}); });
document.body.removeChild(tempDiv); document.body.removeChild(tempDiv);
setVisibleTags(tags.slice(0, visibleCount)); setVisibleTags(tags.slice(0, visibleCount));
setHiddenTags(tags.slice(visibleCount)); setHiddenTags(tags.slice(visibleCount));
}; };
// 延迟执行以确保DOM已渲染 // 延迟执行以确保DOM已渲染
const timer = setTimeout(calculateVisibleTags, 0); const timer = setTimeout(calculateVisibleTags, 0);
// 监听窗口大小变化 // 监听窗口大小变化
const handleResize = () => { const handleResize = () => {
calculateVisibleTags(); calculateVisibleTags();
}; };
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
return () => { return () => {
clearTimeout(timer); clearTimeout(timer);
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
}; };
}, [tags]); }, [tags]);
if (!tags || tags.length === 0) return null; if (!tags || tags.length === 0) return null;
const popoverContent = ( const popoverContent = (
<div className="max-w-xs"> <div className="max-w-xs">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{hiddenTags.map((tag, index) => ( {hiddenTags.map((tag, index) => (
<Tag key={index}>{typeof tag === "string" ? tag : tag.name}</Tag> <Tag key={index}>{typeof tag === "string" ? tag : tag.name}</Tag>
))} ))}
</div> </div>
</div> </div>
); );
return ( return (
<div ref={containerRef} className="flex flex-wrap gap-1 w-full"> <div ref={containerRef} className="flex flex-wrap gap-1 w-full">
{visibleTags.map((tag, index) => ( {visibleTags.map((tag, index) => (
<Tag key={index}>{typeof tag === "string" ? tag : tag.name}</Tag> <Tag key={index}>{typeof tag === "string" ? tag : tag.name}</Tag>
))} ))}
{hiddenTags.length > 0 && ( {hiddenTags.length > 0 && (
<Popover <Popover
content={popoverContent} content={popoverContent}
title="更多标签" title="更多标签"
trigger="hover" trigger="hover"
placement="topLeft" placement="topLeft"
> >
<Tag className="cursor-pointer bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200"> <Tag className="cursor-pointer bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200">
+{hiddenTags.length} +{hiddenTags.length}
</Tag> </Tag>
</Popover> </Popover>
)} )}
</div> </div>
); );
}; };
function CardView<T extends BaseCardDataType>(props: CardViewProps<T>) { function CardView<T extends BaseCardDataType>(props: CardViewProps<T>) {
const { const {
data, data,
pagination, pagination,
operations, operations,
loading, loading,
onView, onView,
onFavorite, onFavorite,
isFavorite, isFavorite,
} = props; } = props;
if (data.length === 0) { if (data.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center h-full text-gray-500"> <div className="flex flex-col items-center justify-center h-full text-gray-500">
<Empty /> <Empty />
</div> </div>
); );
} }
const ops = (item) => const ops = (item) =>
typeof operations === "function" ? operations(item) : operations; typeof operations === "function" ? operations(item) : operations;
return ( return (
<div className="flex-overflow-hidden"> <div className="flex-overflow-hidden">
<div className="overflow-auto grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4"> <div className="overflow-auto grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
{data.map((item) => ( {data.map((item) => (
<div <div
key={item.id} key={item.id}
className="border-card p-4 bg-white hover:shadow-lg transition-shadow duration-200" className="border-card p-4 bg-white duration-200 transition-shadow transition-transform hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(0,0,0,0.06)]"
> >
<div className="flex flex-col space-y-4 h-full"> <div className="flex flex-col space-y-4 h-full">
<div <div
className="flex flex-col space-y-4 h-full" className="flex flex-col space-y-4 h-full"
onClick={() => onView?.(item)} onClick={() => onView?.(item)}
style={{ cursor: onView ? "pointer" : "default" }} style={{ cursor: onView ? "pointer" : "default" }}
> >
{/* Header */} {/* Header */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
{item?.icon && ( {item?.icon && (
<div <div
className={`flex-shrink-0 w-12 h-12 bg-gradient-to-br from-sky-300 to-blue-500 text-white rounded-lg flex items-center justify-center`} className={`flex-shrink-0 w-12 h-12 rounded-lg flex items-center justify-center ${
> item?.iconColor
<div className="w-6 h-6 text-gray-50">{item?.icon}</div> ? ""
</div> : "bg-gradient-to-br from-sky-300 to-blue-500 text-white"
)} }`}
<div className="flex-1 min-w-0"> style={{
<div className="flex items-center gap-2 mb-1"> ...(item?.iconColor
<h3 ? { backgroundColor: item.iconColor }
className={`text-base flex-1 text-ellipsis overflow-hidden whitespace-nowrap font-semibold text-gray-900 truncate`} : {}),
> backgroundImage:
{item?.name} "linear-gradient(180deg, rgba(255,255,255,0.35), rgba(255,255,255,0.05))",
</h3> boxShadow:
{item?.status && ( "inset 0 0 0 1px rgba(255,255,255,0.25)",
<Tag color={item?.status?.color}> }}
<div className="flex items-center gap-2 text-xs py-0.5"> >
{item?.status?.icon && ( <div className="w-[2.1rem] h-[2.1rem] text-gray-50">{item?.icon}</div>
<span>{item?.status?.icon}</span> </div>
)} )}
<span>{item?.status?.label}</span> <div className="flex-1 min-w-0">
</div> <div className="flex items-center gap-2 mb-1">
</Tag> <h3
)} className={`text-base flex-1 text-ellipsis overflow-hidden whitespace-nowrap font-semibold text-gray-900 truncate`}
</div> >
</div> {item?.name}
</div> </h3>
{onFavorite && ( {item?.status && (
<StarFilled <Tag color={item?.status?.color}>
style={{ <div className="flex items-center gap-2 text-xs py-0.5">
fontSize: "16px", {item?.status?.icon && (
color: isFavorite?.(item) ? "#ffcc00ff" : "#d1d5db", <span>{item?.status?.icon}</span>
cursor: "pointer", )}
}} <span>{item?.status?.label}</span>
onClick={() => onFavorite?.(item)} </div>
/> </Tag>
)} )}
</div> </div>
</div>
<div className="flex-1 flex flex-col justify-end"> </div>
{/* Tags */} {onFavorite && (
<TagsRenderer tags={item?.tags || []} /> <StarFilled
style={{
{/* Description */} fontSize: "16px",
<p className="text-gray-600 text-xs text-ellipsis overflow-hidden whitespace-nowrap text-xs line-clamp-2 mt-2"> color: isFavorite?.(item) ? "#ffcc00ff" : "#d1d5db",
<Tooltip title={item?.description}> cursor: "pointer",
{item?.description} }}
</Tooltip> onClick={() => onFavorite?.(item)}
</p> />
)}
{/* Statistics */} </div>
<div className="grid grid-cols-2 gap-4 py-3">
{item?.statistics?.map((stat, idx) => ( <div className="flex-1 flex flex-col justify-end">
<div key={idx}> {/* Tags */}
<div className="text-sm text-gray-500 overflow-hidden whitespace-nowrap text-ellipsis w-full"> <TagsRenderer tags={item?.tags || []} />
{stat?.label}:
</div> {/* Description */}
<div className="text-base font-semibold text-gray-900 overflow-hidden whitespace-nowrap text-ellipsis w-full"> <p className="text-gray-400 text-xs text-ellipsis overflow-hidden whitespace-nowrap text-xs line-clamp-2 mt-2">
{stat?.value} <Tooltip title={item?.description}>
</div> {item?.description}
</div> </Tooltip>
))} </p>
</div>
</div> {/* Statistics */}
</div> <div className="grid grid-cols-2 gap-4 py-3">
{item?.statistics?.map((stat, idx) => (
{/* Actions */} <div key={idx}>
<div className="flex items-center justify-between pt-3 border-t border-t-gray-200"> <div className="text-sm text-gray-500 overflow-hidden whitespace-nowrap text-ellipsis w-full">
<div className=" text-gray-500 text-right"> {stat?.label}:
<div className="flex items-center gap-1"> </div>
<ClockCircleOutlined className="w-4 h-4" />{" "} <div className="text-base font-semibold text-gray-900 overflow-hidden whitespace-nowrap text-ellipsis w-full">
{formatDateTime(item?.updatedAt)} {stat?.value}
</div> </div>
</div> </div>
{operations && ( ))}
<ActionDropdown </div>
actions={ops(item)} </div>
onAction={(key) => { </div>
const operation = ops(item).find((op) => op.key === key);
if (operation?.onClick) { {/* Divider & Actions */}
operation.onClick(item); <div className="w-2/3 border-t border-t-gray-200 mt-2" />
} <div className="flex items-center justify-between pt-3">
}} <div className=" text-gray-500 text-right">
/> <div className="flex items-center gap-1">
)} <ClockCircleOutlined className="w-4 h-4" />{" "}
</div> {formatDateTime(item?.updatedAt)}
</div> </div>
</div> </div>
))} {operations && (
</div> <ActionDropdown
<div className="flex justify-end mt-6"> actions={ops(item)}
<Pagination {...pagination} /> onAction={(key) => {
</div> const operation = ops(item).find((op) => op.key === key);
</div> if (operation?.onClick) {
); operation.onClick(item);
} }
}}
export default CardView; />
)}
</div>
</div>
</div>
))}
</div>
<div className="flex justify-end mt-6">
<Pagination {...pagination} />
</div>
</div>
);
}
export default CardView;

View File

@@ -1,145 +1,150 @@
import React from "react"; import React from "react";
import { Database } from "lucide-react"; import { Database } from "lucide-react";
import { Card, Button, Tag, Tooltip, Popconfirm } from "antd"; import { Card, Button, Tag, Tooltip, Popconfirm } from "antd";
import type { ItemType } from "antd/es/menu/interface"; import type { ItemType } from "antd/es/menu/interface";
import AddTagPopover from "./AddTagPopover"; import AddTagPopover from "./AddTagPopover";
import ActionDropdown from "./ActionDropdown"; import ActionDropdown from "./ActionDropdown";
interface StatisticItem { interface StatisticItem {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
value: string | number; value: string | number;
} }
interface OperationItem { interface OperationItem {
key: string; key: string;
label: string; label: string;
icon?: React.ReactNode; icon?: React.ReactNode;
isDropdown?: boolean; isDropdown?: boolean;
items?: ItemType[]; items?: ItemType[];
onMenuClick?: (key: string) => void; onMenuClick?: (key: string) => void;
onClick?: () => void; onClick?: () => void;
danger?: boolean; danger?: boolean;
} }
interface TagConfig { interface TagConfig {
showAdd: boolean; showAdd: boolean;
tags: { id: number; name: string; color: string }[]; tags: { id: number; name: string; color: string }[];
onFetchTags?: () => Promise<{ onFetchTags?: () => Promise<{
data: { id: number; name: string; color: string }[]; data: { id: number; name: string; color: string }[];
}>; }>;
onAddTag?: (tag: { id: number; name: string; color: string }) => void; onAddTag?: (tag: { id: number; name: string; color: string }) => void;
onCreateAndTag?: (tagName: string) => void; onCreateAndTag?: (tagName: string) => void;
} }
interface DetailHeaderProps<T> { interface DetailHeaderProps<T> {
data: T; data: T;
statistics: StatisticItem[]; statistics: StatisticItem[];
operations: OperationItem[]; operations: OperationItem[];
tagConfig?: TagConfig; tagConfig?: TagConfig;
} }
function DetailHeader<T>({ function DetailHeader<T>({
data = {} as T, data = {} as T,
statistics, statistics,
operations, operations,
tagConfig, tagConfig,
}: DetailHeaderProps<T>): React.ReactNode { }: DetailHeaderProps<T>): React.ReactNode {
return ( return (
<Card> <Card>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-start gap-4 flex-1"> <div className="flex items-start gap-4 flex-1">
<div <div
className={`w-16 h-16 text-white rounded-lg flex-center shadow-lg bg-gradient-to-br from-sky-300 to-blue-500 text-white`} className={`w-16 h-16 text-white rounded-lg flex-center shadow-lg ${
> (data as any)?.iconColor
{<div className="w-8 h-8 text-gray-50">{data?.icon}</div> || ( ? ""
<Database className="w-8 h-8 text-white" /> : "bg-gradient-to-br from-sky-300 to-blue-500 text-white"
)} }`}
</div> style={(data as any)?.iconColor ? { backgroundColor: (data as any).iconColor } : undefined}
<div className="flex-1"> >
<div className="flex items-center gap-3 mb-2"> {<div className="w-[2.8rem] h-[2.8rem] text-gray-50">{(data as any)?.icon}</div> || (
<h1 className="text-lg font-bold text-gray-900">{data?.name}</h1> <Database className="w-8 h-8 text-white" />
{data?.status && ( )}
<Tag color={data.status?.color}> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex-1">
{data.status?.icon && <span>{data.status?.icon}</span>} <div className="flex items-center gap-3 mb-2">
<span>{data.status?.label}</span> <h1 className="text-lg font-bold text-gray-900">{data?.name}</h1>
</div> {data?.status && (
</Tag> <Tag color={data.status?.color}>
)} <div className="flex items-center gap-2 text-xs">
</div> {data.status?.icon && <span>{data.status?.icon}</span>}
{data?.tags && ( <span>{data.status?.label}</span>
<div className="flex flex-wrap mb-2"> </div>
{data?.tags?.map((tag) => ( </Tag>
<Tag key={tag.id} className="mr-1"> )}
{tag.name} </div>
</Tag> {data?.tags && (
))} <div className="flex flex-wrap mb-2">
{tagConfig?.showAdd && ( {data?.tags?.map((tag) => (
<AddTagPopover <Tag key={tag.id} className="mr-1">
tags={tagConfig.tags} {tag.name}
onFetchTags={tagConfig.onFetchTags} </Tag>
onAddTag={tagConfig.onAddTag} ))}
onCreateAndTag={tagConfig.onCreateAndTag} {tagConfig?.showAdd && (
/> <AddTagPopover
)} tags={tagConfig.tags}
</div> onFetchTags={tagConfig.onFetchTags}
)} onAddTag={tagConfig.onAddTag}
<p className="text-gray-700 mb-4">{data?.description}</p> onCreateAndTag={tagConfig.onCreateAndTag}
<div className="flex items-center gap-6 text-sm"> />
{statistics.map((stat) => ( )}
<div key={stat.key} className="flex items-center gap-1"> </div>
{stat.icon} )}
<span>{stat.value}</span> <p className="text-gray-700 mb-4">{data?.description}</p>
</div> <div className="flex items-center gap-6 text-sm">
))} {statistics.map((stat) => (
</div> <div key={stat.key} className="flex items-center gap-1">
</div> {stat.icon}
</div> <span>{stat.value}</span>
<div className="flex items-center gap-2"> </div>
{operations.map((op) => { ))}
if (op.isDropdown) { </div>
return ( </div>
<ActionDropdown </div>
actions={op?.items} <div className="flex items-center gap-2">
onAction={op?.onMenuClick} {operations.map((op) => {
/> if (op.isDropdown) {
); return (
} <ActionDropdown
if (op.confirm) { actions={op?.items}
return ( onAction={op?.onMenuClick}
<Tooltip key={op.key} title={op.label}> />
<Popconfirm );
key={op.key} }
{...op.confirm} if (op.confirm) {
onConfirm={() => { return (
if (op.onClick) { <Tooltip key={op.key} title={op.label}>
op.onClick() <Popconfirm
} else { key={op.key}
op?.confirm?.onConfirm?.(); {...op.confirm}
} onConfirm={() => {
}} if (op.onClick) {
okType={op.danger ? "danger" : "primary"} op.onClick()
overlayStyle={{ zIndex: 9999 }} } else {
> op?.confirm?.onConfirm?.();
<Button icon={op.icon} danger={op.danger} /> }
</Popconfirm> }}
</Tooltip> okType={op.danger ? "danger" : "primary"}
); overlayStyle={{ zIndex: 9999 }}
} >
return ( <Button icon={op.icon} danger={op.danger} />
<Tooltip key={op.key} title={op.label}> </Popconfirm>
<Button </Tooltip>
icon={op.icon} );
danger={op.danger} }
onClick={op.onClick} return (
/> <Tooltip key={op.key} title={op.label}>
</Tooltip> <Button
); icon={op.icon}
})} danger={op.danger}
</div> onClick={op.onClick}
</div> />
</Card> </Tooltip>
); );
} })}
</div>
export default DetailHeader; </div>
</Card>
);
}
export default DetailHeader;

View File

@@ -1,90 +1,101 @@
import { Button, List, Tag } from "antd"; import { Button, List, Tag } from "antd";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { Operator } from "../../operator.model"; import { Operator } from "../../operator.model";
export function ListView({ operators = [], pagination, operations }) { export function ListView({ operators = [], pagination, operations }) {
const navigate = useNavigate(); const navigate = useNavigate();
const handleViewOperator = (operator: Operator) => { const handleViewOperator = (operator: Operator) => {
navigate(`/data/operator-market/plugin-detail/${operator.id}`); navigate(`/data/operator-market/plugin-detail/${operator.id}`);
}; };
return ( return (
<List <List
className="p-4 flex-1 overflow-auto mx-4" className="p-4 flex-1 overflow-auto mx-4"
dataSource={operators} dataSource={operators}
pagination={pagination} pagination={pagination}
renderItem={(operator) => ( renderItem={(operator) => (
<List.Item <List.Item
className="hover:bg-gray-50 transition-colors px-6 py-4" className="hover:bg-gray-50 transition-colors px-6 py-4"
actions={[ actions={[
// <Button // <Button
// key="favorite" // key="favorite"
// type="text" // type="text"
// size="small" // size="small"
// onClick={() => handleToggleFavorite(operator.id)} // onClick={() => handleToggleFavorite(operator.id)}
// className={ // className={
// favoriteOperators.has(operator.id) // favoriteOperators.has(operator.id)
// ? "text-yellow-500 hover:text-yellow-600" // ? "text-yellow-500 hover:text-yellow-600"
// : "text-gray-400 hover:text-yellow-500" // : "text-gray-400 hover:text-yellow-500"
// } // }
// icon={ // icon={
// <StarFilled // <StarFilled
// style={{ // style={{
// fontSize: "16px", // fontSize: "16px",
// color: favoriteOperators.has(operator.id) // color: favoriteOperators.has(operator.id)
// ? "#ffcc00ff" // ? "#ffcc00ff"
// : "#d1d5db", // : "#d1d5db",
// cursor: "pointer", // cursor: "pointer",
// }} // }}
// onClick={() => handleToggleFavorite(operator.id)} // onClick={() => handleToggleFavorite(operator.id)}
// /> // />
// } // }
// title="收藏" // title="收藏"
// />, // />,
...operations.map((operation) => ( ...operations.map((operation) => (
<Button <Button
type="text" type="text"
size="small" size="small"
title={operation.label} title={operation.label}
icon={operation.icon} icon={operation.icon}
danger={operation.danger} danger={operation.danger}
onClick={() => operation.onClick(operator)} onClick={() => operation.onClick(operator)}
/> />
)), )),
]} ]}
> >
<List.Item.Meta <List.Item.Meta
avatar={ avatar={
<div className="w-12 h-12 bg-gradient-to-br from-sky-300 to-blue-500 rounded-lg flex items-center justify-center"> <div
<div className="w-8 h-8 text-white">{operator?.icon}</div> className={`w-12 h-12 rounded-lg flex items-center justify-center ${
</div> operator?.iconColor
} ? ""
title={ : "bg-gradient-to-br from-sky-300 to-blue-500"
<div className="flex items-center gap-3"> }`}
<span style={
className="font-medium text-gray-900 cursor-pointer hover:text-blue-600" operator?.iconColor
onClick={() => handleViewOperator(operator)} ? { backgroundColor: operator.iconColor }
> : undefined
{operator.name} }
</span> >
<Tag color="default">v{operator.version}</Tag> <div className="w-[2.8rem] h-[2.8rem] text-white">{operator?.icon}</div>
</div> </div>
} }
description={ title={
<div className="space-y-2"> <div className="flex items-center gap-3">
<div className="text-gray-600 ">{operator.description}</div> <span
{/* <div className="flex items-center gap-4 text-xs text-gray-500"> className="font-medium text-gray-900 cursor-pointer hover:text-blue-600"
<span>作者: {operator.author}</span> onClick={() => handleViewOperator(operator)}
<span>类型: {operator.type}</span> >
<span>框架: {operator.framework}</span> {operator.name}
<span>使用次数: {operator?.usage?.toLocaleString()}</span> </span>
</div> */} <Tag color="default">v{operator.version}</Tag>
</div> </div>
} }
/> description={
</List.Item> <div className="space-y-2">
)} <div className="text-gray-600 ">{operator.description}</div>
/> {/* <div className="flex items-center gap-4 text-xs text-gray-500">
); <span>作者: {operator.author}</span>
} <span>类型: {operator.type}</span>
<span>框架: {operator.framework}</span>
<span>使用次数: {operator?.usage?.toLocaleString()}</span>
</div> */}
</div>
}
/>
</List.Item>
)}
/>
);
}

View File

@@ -1,12 +1,59 @@
import { Code } from "lucide-react"; import React from "react";
import { OperatorI } from "./operator.model"; import { Code, FileSliders, Image } from "lucide-react";
import {formatDateTime} from "@/utils/unit.ts"; import { OperatorI } from "./operator.model";
import { formatDateTime } from "@/utils/unit.ts";
export const mapOperator = (op: OperatorI) => {
return { const getOperatorVisual = (
...op, op: OperatorI
icon: <Code className="w-full h-full" />, ): { icon: React.ReactNode; iconColor?: string } => {
createdAt: formatDateTime(op?.createdAt) || "--", const type = (op?.type || "").toLowerCase();
updatedAt: formatDateTime(op?.updatedAt) || formatDateTime(op?.createdAt) || "--", const categories = (op?.categories || []).map((c) => (c || "").toLowerCase());
}; const inputs = (op?.inputs || "").toLowerCase();
}; const outputs = (op?.outputs || "").toLowerCase();
const isImageOp =
["image", "图像", "图像类"].includes(type) ||
categories.some((c) => c.includes("image") || c.includes("图像")) ||
inputs.includes("image") ||
outputs.includes("image");
const isTextOp =
["text", "文本", "文本类"].includes(type) ||
categories.some((c) => c.includes("text") || c.includes("文本")) ||
inputs.includes("text") ||
outputs.includes("text");
if (isImageOp) {
return {
icon: <Image className="w-full h-full" />,
iconColor: "#38BDF8", // 图像算子背景色
};
}
if (isTextOp) {
return {
icon: <FileSliders className="w-full h-full" />,
iconColor: "#A78BFA", // 文本算子背景色
};
}
return {
icon: <Code className="w-full h-full" />,
iconColor: undefined,
};
};
export const mapOperator = (op: OperatorI) => {
const visual = getOperatorVisual(op);
return {
...op,
icon: visual.icon,
iconColor: visual.iconColor,
createdAt: formatDateTime(op?.createdAt) || "--",
updatedAt:
formatDateTime(op?.updatedAt) ||
formatDateTime(op?.createdAt) ||
"--",
};
};

View File

@@ -1,62 +1,63 @@
export interface ConfigI { export interface ConfigI {
type: type:
| "input" | "input"
| "select" | "select"
| "radio" | "radio"
| "checkbox" | "checkbox"
| "range" | "range"
| "slider" | "slider"
| "inputNumber" | "inputNumber"
| "switch" | "switch"
| "multiple"; | "multiple";
value?: number | string | boolean | string[] | number[]; value?: number | string | boolean | string[] | number[];
required?: boolean; required?: boolean;
description?: string; description?: string;
key: string; key: string;
defaultVal: number | string | boolean | string[]; defaultVal: number | string | boolean | string[];
options?: string[] | { label: string; value: string }[]; options?: string[] | { label: string; value: string }[];
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
properties?: ConfigI[]; // 用于嵌套配置 properties?: ConfigI[]; // 用于嵌套配置
} }
export interface OperatorI { export interface OperatorI {
id: string; id: string;
name: string; name: string;
type: string; type: string;
version: string; version: string;
inputs: string; inputs: string;
outputs: string; outputs: string;
icon: React.ReactNode; icon: React.ReactNode;
description: string; iconColor?: string; // 图标背景色,用于区分不同类型算子
tags: string[]; description: string;
isStar?: boolean; tags: string[];
originalId?: string; // 用于标识原始算子ID,便于去重 isStar?: boolean;
categories: string[]; // 分类列表 originalId?: string; // 用于标识原始算子ID,便于去重
settings: string; categories: string[]; // 分类列表
overrides?: { [key: string]: any }; // 用户配置的参数 settings: string;
defaultParams?: { [key: string]: any }; // 默认参数 overrides?: { [key: string]: any }; // 用户配置的参数
configs: { defaultParams?: { [key: string]: any }; // 默认参数
[key: string]: ConfigI; configs: {
}; [key: string]: ConfigI;
createdAt?: string; };
updatedAt?: string; createdAt?: string;
} updatedAt?: string;
}
export interface CategoryI {
id: number; export interface CategoryI {
name: string; id: number;
count: number; // 该分类下的算子数量 name: string;
type: string; // e.g., "数据源", "数据清洗", "数据分析", "数据可视化" count: number; // 该分类下的算子数量
parentId?: number; // 父分类ID,若无父分类则为null type: string; // e.g., "数据源", "数据清洗", "数据分析", "数据可视化"
value: string; parentId?: number; // 父分类ID,若无父分类则为null
createdAt: string; value: string;
} createdAt: string;
}
export interface CategoryTreeI {
id: string; export interface CategoryTreeI {
name: string; id: string;
count: number; name: string;
categories: CategoryI[]; count: number;
} categories: CategoryI[];
}