You've already forked DataMate
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:
@@ -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<T> {
|
||||
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<any[]>([]);
|
||||
const [hiddenTags, setHiddenTags] = useState<any[]>([]);
|
||||
const containerRef = useRef<HTMLDivElement>(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 = (
|
||||
<div className="max-w-xs">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{hiddenTags.map((tag, index) => (
|
||||
<Tag key={index}>{typeof tag === "string" ? tag : tag.name}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-wrap gap-1 w-full">
|
||||
{visibleTags.map((tag, index) => (
|
||||
<Tag key={index}>{typeof tag === "string" ? tag : tag.name}</Tag>
|
||||
))}
|
||||
{hiddenTags.length > 0 && (
|
||||
<Popover
|
||||
content={popoverContent}
|
||||
title="更多标签"
|
||||
trigger="hover"
|
||||
placement="topLeft"
|
||||
>
|
||||
<Tag className="cursor-pointer bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200">
|
||||
+{hiddenTags.length}
|
||||
</Tag>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function CardView<T extends BaseCardDataType>(props: CardViewProps<T>) {
|
||||
const {
|
||||
data,
|
||||
pagination,
|
||||
operations,
|
||||
loading,
|
||||
onView,
|
||||
onFavorite,
|
||||
isFavorite,
|
||||
} = props;
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
||||
<Empty />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ops = (item) =>
|
||||
typeof operations === "function" ? operations(item) : operations;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{data.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border-card p-4 bg-white hover:shadow-lg transition-shadow duration-200"
|
||||
>
|
||||
<div className="flex flex-col space-y-4 h-full">
|
||||
<div
|
||||
className="flex flex-col space-y-4 h-full"
|
||||
onClick={() => onView?.(item)}
|
||||
style={{ cursor: onView ? "pointer" : "default" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{item?.icon && (
|
||||
<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`}
|
||||
>
|
||||
<div className="w-6 h-6 text-gray-50">{item?.icon}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3
|
||||
className={`text-base flex-1 text-ellipsis overflow-hidden whitespace-nowrap font-semibold text-gray-900 truncate`}
|
||||
>
|
||||
{item?.name}
|
||||
</h3>
|
||||
{item?.status && (
|
||||
<Tag color={item?.status?.color}>
|
||||
<div className="flex items-center gap-2 text-xs py-0.5">
|
||||
{item?.status?.icon && (
|
||||
<span>{item?.status?.icon}</span>
|
||||
)}
|
||||
<span>{item?.status?.label}</span>
|
||||
</div>
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onFavorite && (
|
||||
<StarFilled
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
color: isFavorite?.(item) ? "#ffcc00ff" : "#d1d5db",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => onFavorite?.(item)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col justify-end">
|
||||
{/* Tags */}
|
||||
<TagsRenderer tags={item?.tags || []} />
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-gray-600 text-xs text-ellipsis overflow-hidden whitespace-nowrap text-xs line-clamp-2 mt-2">
|
||||
<Tooltip title={item?.description}>
|
||||
{item?.description}
|
||||
</Tooltip>
|
||||
</p>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-2 gap-4 py-3">
|
||||
{item?.statistics?.map((stat, idx) => (
|
||||
<div key={idx}>
|
||||
<div className="text-sm text-gray-500 overflow-hidden whitespace-nowrap text-ellipsis w-full">
|
||||
{stat?.label}:
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 overflow-hidden whitespace-nowrap text-ellipsis w-full">
|
||||
{stat?.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-t-gray-200">
|
||||
<div className=" text-gray-500 text-right">
|
||||
<div className="flex items-center gap-1">
|
||||
<ClockCircleOutlined className="w-4 h-4" />{" "}
|
||||
{formatDateTime(item?.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
{operations && (
|
||||
<ActionDropdown
|
||||
actions={ops(item)}
|
||||
onAction={(key) => {
|
||||
const operation = ops(item).find((op) => op.key === key);
|
||||
if (operation?.onClick) {
|
||||
operation.onClick(item);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end mt-6">
|
||||
<Pagination {...pagination} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<T> {
|
||||
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<any[]>([]);
|
||||
const [hiddenTags, setHiddenTags] = useState<any[]>([]);
|
||||
const containerRef = useRef<HTMLDivElement>(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 = (
|
||||
<div className="max-w-xs">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{hiddenTags.map((tag, index) => (
|
||||
<Tag key={index}>{typeof tag === "string" ? tag : tag.name}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-wrap gap-1 w-full">
|
||||
{visibleTags.map((tag, index) => (
|
||||
<Tag key={index}>{typeof tag === "string" ? tag : tag.name}</Tag>
|
||||
))}
|
||||
{hiddenTags.length > 0 && (
|
||||
<Popover
|
||||
content={popoverContent}
|
||||
title="更多标签"
|
||||
trigger="hover"
|
||||
placement="topLeft"
|
||||
>
|
||||
<Tag className="cursor-pointer bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200">
|
||||
+{hiddenTags.length}
|
||||
</Tag>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function CardView<T extends BaseCardDataType>(props: CardViewProps<T>) {
|
||||
const {
|
||||
data,
|
||||
pagination,
|
||||
operations,
|
||||
loading,
|
||||
onView,
|
||||
onFavorite,
|
||||
isFavorite,
|
||||
} = props;
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
||||
<Empty />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ops = (item) =>
|
||||
typeof operations === "function" ? operations(item) : operations;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{data.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
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"
|
||||
onClick={() => onView?.(item)}
|
||||
style={{ cursor: onView ? "pointer" : "default" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{item?.icon && (
|
||||
<div
|
||||
className={`flex-shrink-0 w-12 h-12 rounded-lg flex items-center justify-center ${
|
||||
item?.iconColor
|
||||
? ""
|
||||
: "bg-gradient-to-br from-sky-300 to-blue-500 text-white"
|
||||
}`}
|
||||
style={{
|
||||
...(item?.iconColor
|
||||
? { backgroundColor: item.iconColor }
|
||||
: {}),
|
||||
backgroundImage:
|
||||
"linear-gradient(180deg, rgba(255,255,255,0.35), rgba(255,255,255,0.05))",
|
||||
boxShadow:
|
||||
"inset 0 0 0 1px rgba(255,255,255,0.25)",
|
||||
}}
|
||||
>
|
||||
<div className="w-[2.1rem] h-[2.1rem] text-gray-50">{item?.icon}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3
|
||||
className={`text-base flex-1 text-ellipsis overflow-hidden whitespace-nowrap font-semibold text-gray-900 truncate`}
|
||||
>
|
||||
{item?.name}
|
||||
</h3>
|
||||
{item?.status && (
|
||||
<Tag color={item?.status?.color}>
|
||||
<div className="flex items-center gap-2 text-xs py-0.5">
|
||||
{item?.status?.icon && (
|
||||
<span>{item?.status?.icon}</span>
|
||||
)}
|
||||
<span>{item?.status?.label}</span>
|
||||
</div>
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onFavorite && (
|
||||
<StarFilled
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
color: isFavorite?.(item) ? "#ffcc00ff" : "#d1d5db",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => onFavorite?.(item)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col justify-end">
|
||||
{/* Tags */}
|
||||
<TagsRenderer tags={item?.tags || []} />
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-gray-400 text-xs text-ellipsis overflow-hidden whitespace-nowrap text-xs line-clamp-2 mt-2">
|
||||
<Tooltip title={item?.description}>
|
||||
{item?.description}
|
||||
</Tooltip>
|
||||
</p>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-2 gap-4 py-3">
|
||||
{item?.statistics?.map((stat, idx) => (
|
||||
<div key={idx}>
|
||||
<div className="text-sm text-gray-500 overflow-hidden whitespace-nowrap text-ellipsis w-full">
|
||||
{stat?.label}:
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900 overflow-hidden whitespace-nowrap text-ellipsis w-full">
|
||||
{stat?.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider & Actions */}
|
||||
<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" />{" "}
|
||||
{formatDateTime(item?.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
{operations && (
|
||||
<ActionDropdown
|
||||
actions={ops(item)}
|
||||
onAction={(key) => {
|
||||
const operation = ops(item).find((op) => op.key === key);
|
||||
if (operation?.onClick) {
|
||||
operation.onClick(item);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end mt-6">
|
||||
<Pagination {...pagination} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CardView;
|
||||
|
||||
@@ -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<T> {
|
||||
data: T;
|
||||
statistics: StatisticItem[];
|
||||
operations: OperationItem[];
|
||||
tagConfig?: TagConfig;
|
||||
}
|
||||
|
||||
function DetailHeader<T>({
|
||||
data = {} as T,
|
||||
statistics,
|
||||
operations,
|
||||
tagConfig,
|
||||
}: DetailHeaderProps<T>): React.ReactNode {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<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`}
|
||||
>
|
||||
{<div className="w-8 h-8 text-gray-50">{data?.icon}</div> || (
|
||||
<Database className="w-8 h-8 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-lg font-bold text-gray-900">{data?.name}</h1>
|
||||
{data?.status && (
|
||||
<Tag color={data.status?.color}>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{data.status?.icon && <span>{data.status?.icon}</span>}
|
||||
<span>{data.status?.label}</span>
|
||||
</div>
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{data?.tags && (
|
||||
<div className="flex flex-wrap mb-2">
|
||||
{data?.tags?.map((tag) => (
|
||||
<Tag key={tag.id} className="mr-1">
|
||||
{tag.name}
|
||||
</Tag>
|
||||
))}
|
||||
{tagConfig?.showAdd && (
|
||||
<AddTagPopover
|
||||
tags={tagConfig.tags}
|
||||
onFetchTags={tagConfig.onFetchTags}
|
||||
onAddTag={tagConfig.onAddTag}
|
||||
onCreateAndTag={tagConfig.onCreateAndTag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-gray-700 mb-4">{data?.description}</p>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
{statistics.map((stat) => (
|
||||
<div key={stat.key} className="flex items-center gap-1">
|
||||
{stat.icon}
|
||||
<span>{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{operations.map((op) => {
|
||||
if (op.isDropdown) {
|
||||
return (
|
||||
<ActionDropdown
|
||||
actions={op?.items}
|
||||
onAction={op?.onMenuClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (op.confirm) {
|
||||
return (
|
||||
<Tooltip key={op.key} title={op.label}>
|
||||
<Popconfirm
|
||||
key={op.key}
|
||||
{...op.confirm}
|
||||
onConfirm={() => {
|
||||
if (op.onClick) {
|
||||
op.onClick()
|
||||
} else {
|
||||
op?.confirm?.onConfirm?.();
|
||||
}
|
||||
}}
|
||||
okType={op.danger ? "danger" : "primary"}
|
||||
overlayStyle={{ zIndex: 9999 }}
|
||||
>
|
||||
<Button icon={op.icon} danger={op.danger} />
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip key={op.key} title={op.label}>
|
||||
<Button
|
||||
icon={op.icon}
|
||||
danger={op.danger}
|
||||
onClick={op.onClick}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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<T> {
|
||||
data: T;
|
||||
statistics: StatisticItem[];
|
||||
operations: OperationItem[];
|
||||
tagConfig?: TagConfig;
|
||||
}
|
||||
|
||||
function DetailHeader<T>({
|
||||
data = {} as T,
|
||||
statistics,
|
||||
operations,
|
||||
tagConfig,
|
||||
}: DetailHeaderProps<T>): React.ReactNode {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<div
|
||||
className={`w-16 h-16 text-white rounded-lg flex-center shadow-lg ${
|
||||
(data as any)?.iconColor
|
||||
? ""
|
||||
: "bg-gradient-to-br from-sky-300 to-blue-500 text-white"
|
||||
}`}
|
||||
style={(data as any)?.iconColor ? { backgroundColor: (data as any).iconColor } : undefined}
|
||||
>
|
||||
{<div className="w-[2.8rem] h-[2.8rem] text-gray-50">{(data as any)?.icon}</div> || (
|
||||
<Database className="w-8 h-8 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-lg font-bold text-gray-900">{data?.name}</h1>
|
||||
{data?.status && (
|
||||
<Tag color={data.status?.color}>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{data.status?.icon && <span>{data.status?.icon}</span>}
|
||||
<span>{data.status?.label}</span>
|
||||
</div>
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{data?.tags && (
|
||||
<div className="flex flex-wrap mb-2">
|
||||
{data?.tags?.map((tag) => (
|
||||
<Tag key={tag.id} className="mr-1">
|
||||
{tag.name}
|
||||
</Tag>
|
||||
))}
|
||||
{tagConfig?.showAdd && (
|
||||
<AddTagPopover
|
||||
tags={tagConfig.tags}
|
||||
onFetchTags={tagConfig.onFetchTags}
|
||||
onAddTag={tagConfig.onAddTag}
|
||||
onCreateAndTag={tagConfig.onCreateAndTag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-gray-700 mb-4">{data?.description}</p>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
{statistics.map((stat) => (
|
||||
<div key={stat.key} className="flex items-center gap-1">
|
||||
{stat.icon}
|
||||
<span>{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{operations.map((op) => {
|
||||
if (op.isDropdown) {
|
||||
return (
|
||||
<ActionDropdown
|
||||
actions={op?.items}
|
||||
onAction={op?.onMenuClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (op.confirm) {
|
||||
return (
|
||||
<Tooltip key={op.key} title={op.label}>
|
||||
<Popconfirm
|
||||
key={op.key}
|
||||
{...op.confirm}
|
||||
onConfirm={() => {
|
||||
if (op.onClick) {
|
||||
op.onClick()
|
||||
} else {
|
||||
op?.confirm?.onConfirm?.();
|
||||
}
|
||||
}}
|
||||
okType={op.danger ? "danger" : "primary"}
|
||||
overlayStyle={{ zIndex: 9999 }}
|
||||
>
|
||||
<Button icon={op.icon} danger={op.danger} />
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip key={op.key} title={op.label}>
|
||||
<Button
|
||||
icon={op.icon}
|
||||
danger={op.danger}
|
||||
onClick={op.onClick}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailHeader;
|
||||
|
||||
@@ -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 (
|
||||
<List
|
||||
className="p-4 flex-1 overflow-auto mx-4"
|
||||
dataSource={operators}
|
||||
pagination={pagination}
|
||||
renderItem={(operator) => (
|
||||
<List.Item
|
||||
className="hover:bg-gray-50 transition-colors px-6 py-4"
|
||||
actions={[
|
||||
// <Button
|
||||
// key="favorite"
|
||||
// type="text"
|
||||
// size="small"
|
||||
// onClick={() => handleToggleFavorite(operator.id)}
|
||||
// className={
|
||||
// favoriteOperators.has(operator.id)
|
||||
// ? "text-yellow-500 hover:text-yellow-600"
|
||||
// : "text-gray-400 hover:text-yellow-500"
|
||||
// }
|
||||
// icon={
|
||||
// <StarFilled
|
||||
// style={{
|
||||
// fontSize: "16px",
|
||||
// color: favoriteOperators.has(operator.id)
|
||||
// ? "#ffcc00ff"
|
||||
// : "#d1d5db",
|
||||
// cursor: "pointer",
|
||||
// }}
|
||||
// onClick={() => handleToggleFavorite(operator.id)}
|
||||
// />
|
||||
// }
|
||||
// title="收藏"
|
||||
// />,
|
||||
...operations.map((operation) => (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
title={operation.label}
|
||||
icon={operation.icon}
|
||||
danger={operation.danger}
|
||||
onClick={() => operation.onClick(operator)}
|
||||
/>
|
||||
)),
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
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 className="w-8 h-8 text-white">{operator?.icon}</div>
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="font-medium text-gray-900 cursor-pointer hover:text-blue-600"
|
||||
onClick={() => handleViewOperator(operator)}
|
||||
>
|
||||
{operator.name}
|
||||
</span>
|
||||
<Tag color="default">v{operator.version}</Tag>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<List
|
||||
className="p-4 flex-1 overflow-auto mx-4"
|
||||
dataSource={operators}
|
||||
pagination={pagination}
|
||||
renderItem={(operator) => (
|
||||
<List.Item
|
||||
className="hover:bg-gray-50 transition-colors px-6 py-4"
|
||||
actions={[
|
||||
// <Button
|
||||
// key="favorite"
|
||||
// type="text"
|
||||
// size="small"
|
||||
// onClick={() => handleToggleFavorite(operator.id)}
|
||||
// className={
|
||||
// favoriteOperators.has(operator.id)
|
||||
// ? "text-yellow-500 hover:text-yellow-600"
|
||||
// : "text-gray-400 hover:text-yellow-500"
|
||||
// }
|
||||
// icon={
|
||||
// <StarFilled
|
||||
// style={{
|
||||
// fontSize: "16px",
|
||||
// color: favoriteOperators.has(operator.id)
|
||||
// ? "#ffcc00ff"
|
||||
// : "#d1d5db",
|
||||
// cursor: "pointer",
|
||||
// }}
|
||||
// onClick={() => handleToggleFavorite(operator.id)}
|
||||
// />
|
||||
// }
|
||||
// title="收藏"
|
||||
// />,
|
||||
...operations.map((operation) => (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
title={operation.label}
|
||||
icon={operation.icon}
|
||||
danger={operation.danger}
|
||||
onClick={() => operation.onClick(operator)}
|
||||
/>
|
||||
)),
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<div
|
||||
className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
||||
operator?.iconColor
|
||||
? ""
|
||||
: "bg-gradient-to-br from-sky-300 to-blue-500"
|
||||
}`}
|
||||
style={
|
||||
operator?.iconColor
|
||||
? { backgroundColor: operator.iconColor }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="w-[2.8rem] h-[2.8rem] text-white">{operator?.icon}</div>
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="font-medium text-gray-900 cursor-pointer hover:text-blue-600"
|
||||
onClick={() => handleViewOperator(operator)}
|
||||
>
|
||||
{operator.name}
|
||||
</span>
|
||||
<Tag color="default">v{operator.version}</Tag>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,59 @@
|
||||
import { Code } from "lucide-react";
|
||||
import { OperatorI } from "./operator.model";
|
||||
import {formatDateTime} from "@/utils/unit.ts";
|
||||
|
||||
export const mapOperator = (op: OperatorI) => {
|
||||
return {
|
||||
...op,
|
||||
icon: <Code className="w-full h-full" />,
|
||||
createdAt: formatDateTime(op?.createdAt) || "--",
|
||||
updatedAt: formatDateTime(op?.updatedAt) || formatDateTime(op?.createdAt) || "--",
|
||||
};
|
||||
};
|
||||
import React from "react";
|
||||
import { Code, FileSliders, Image } from "lucide-react";
|
||||
import { OperatorI } from "./operator.model";
|
||||
import { formatDateTime } from "@/utils/unit.ts";
|
||||
|
||||
const getOperatorVisual = (
|
||||
op: OperatorI
|
||||
): { icon: React.ReactNode; iconColor?: string } => {
|
||||
const type = (op?.type || "").toLowerCase();
|
||||
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) ||
|
||||
"--",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,62 +1,63 @@
|
||||
export interface ConfigI {
|
||||
type:
|
||||
| "input"
|
||||
| "select"
|
||||
| "radio"
|
||||
| "checkbox"
|
||||
| "range"
|
||||
| "slider"
|
||||
| "inputNumber"
|
||||
| "switch"
|
||||
| "multiple";
|
||||
value?: number | string | boolean | string[] | number[];
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
key: string;
|
||||
defaultVal: number | string | boolean | string[];
|
||||
options?: string[] | { label: string; value: string }[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
properties?: ConfigI[]; // 用于嵌套配置
|
||||
}
|
||||
|
||||
export interface OperatorI {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
version: string;
|
||||
inputs: string;
|
||||
outputs: string;
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
tags: string[];
|
||||
isStar?: boolean;
|
||||
originalId?: string; // 用于标识原始算子ID,便于去重
|
||||
categories: string[]; // 分类列表
|
||||
settings: string;
|
||||
overrides?: { [key: string]: any }; // 用户配置的参数
|
||||
defaultParams?: { [key: string]: any }; // 默认参数
|
||||
configs: {
|
||||
[key: string]: ConfigI;
|
||||
};
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CategoryI {
|
||||
id: number;
|
||||
name: string;
|
||||
count: number; // 该分类下的算子数量
|
||||
type: string; // e.g., "数据源", "数据清洗", "数据分析", "数据可视化"
|
||||
parentId?: number; // 父分类ID,若无父分类则为null
|
||||
value: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CategoryTreeI {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
categories: CategoryI[];
|
||||
}
|
||||
export interface ConfigI {
|
||||
type:
|
||||
| "input"
|
||||
| "select"
|
||||
| "radio"
|
||||
| "checkbox"
|
||||
| "range"
|
||||
| "slider"
|
||||
| "inputNumber"
|
||||
| "switch"
|
||||
| "multiple";
|
||||
value?: number | string | boolean | string[] | number[];
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
key: string;
|
||||
defaultVal: number | string | boolean | string[];
|
||||
options?: string[] | { label: string; value: string }[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
properties?: ConfigI[]; // 用于嵌套配置
|
||||
}
|
||||
|
||||
export interface OperatorI {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
version: string;
|
||||
inputs: string;
|
||||
outputs: string;
|
||||
icon: React.ReactNode;
|
||||
iconColor?: string; // 图标背景色,用于区分不同类型算子
|
||||
description: string;
|
||||
tags: string[];
|
||||
isStar?: boolean;
|
||||
originalId?: string; // 用于标识原始算子ID,便于去重
|
||||
categories: string[]; // 分类列表
|
||||
settings: string;
|
||||
overrides?: { [key: string]: any }; // 用户配置的参数
|
||||
defaultParams?: { [key: string]: any }; // 默认参数
|
||||
configs: {
|
||||
[key: string]: ConfigI;
|
||||
};
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CategoryI {
|
||||
id: number;
|
||||
name: string;
|
||||
count: number; // 该分类下的算子数量
|
||||
type: string; // e.g., "数据源", "数据清洗", "数据分析", "数据可视化"
|
||||
parentId?: number; // 父分类ID,若无父分类则为null
|
||||
value: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CategoryTreeI {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
categories: CategoryI[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user