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 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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) ||
|
||||||
|
"--",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user