diff --git a/frontend/src/components/AddTagPopover.tsx b/frontend/src/components/AddTagPopover.tsx index 8ce7370c5..2b240ce58 100644 --- a/frontend/src/components/AddTagPopover.tsx +++ b/frontend/src/components/AddTagPopover.tsx @@ -12,7 +12,7 @@ interface Tag { interface AddTagPopoverProps { tags: Tag[]; onFetchTags?: () => Promise; - onAddTag?: (tag: Tag) => void; + onAddTag?: (tag: string) => void; onCreateAndTag?: (tagName: string) => void; } @@ -39,7 +39,7 @@ export default function AddTagPopover({ }; useEffect(() => { fetchTags(); - }, [showPopover]); + }, [showPopover, onFetchTags]); const availableTags = useMemo(() => { return allTags.filter((tag) => !tagsSet.has(tag.id)); diff --git a/frontend/src/components/CardView.tsx b/frontend/src/components/CardView.tsx index 84a29f6c9..49baf4e82 100644 --- a/frontend/src/components/CardView.tsx +++ b/frontend/src/components/CardView.tsx @@ -55,70 +55,78 @@ interface CardViewProps { // 标签渲染组件 const TagsRenderer = ({ tags }: { tags?: Array }) => { - const [visibleTags, setVisibleTags] = useState([]); + const [visibleTags, setVisibleTags] = useState(tags || []); const [hiddenTags, setHiddenTags] = useState([]); const containerRef = useRef(null); + const isInitializedRef = useRef(false); + const prevTagsRef = useRef(); useEffect(() => { - if (!tags || tags.length === 0) return; + if (!tags || tags.length === 0) { + setVisibleTags([]); + setHiddenTags([]); + 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); + const container = containerRef.current; + const containerRect = container.getBoundingClientRect(); - let totalWidth = 0; - let visibleCount = 0; - const tagElements: HTMLElement[] = []; + // 获取所有标签元素 + const tagElements = Array.from(container.querySelectorAll(':scope > .ant-tag')); + const plusTagElement = tagElements.find(el => el.textContent?.startsWith('+')); + const regularTags = tagElements.filter(el => !el.textContent?.startsWith('+')); - // 为每个tag创建临时元素来测量宽度 - tags.forEach((tag, index) => { - const tagElement = document.createElement("span"); - tagElement.className = "ant-tag ant-tag-default"; - tagElement.style.margin = "2px"; - if (typeof tag === "string") { - tagElement.textContent = tag; - } else { - tagElement.textContent = tag.label ? tag.label : tag.name; - } - tempDiv.appendChild(tagElement); - tagElements.push(tagElement); + // 动态测量 "+n" 标签宽度 + let plusWidth = 0; + if (plusTagElement) { + plusWidth = plusTagElement.offsetWidth; + } else { + // 如果 "+n" 标签不存在,创建一个临时元素来测量 + const tempPlus = document.createElement('span'); + tempPlus.className = 'ant-tag ant-tag-default cursor-pointer bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200'; + tempPlus.textContent = '+99'; + tempPlus.style.position = 'absolute'; + tempPlus.style.visibility = 'hidden'; + container.appendChild(tempPlus); + plusWidth = tempPlus.offsetWidth; + container.removeChild(tempPlus); + } + + // 检查每个标签是否超出 + let visibleCount = 0; + const containerRight = containerRect.right; + const gap = 4; // gap-1 的宽度 - const tagWidth = tagElement.offsetWidth + 4; // 加上gap的宽度 + regularTags.forEach((tagEl, index) => { + const tagRect = tagEl.getBoundingClientRect(); + const tagRight = tagRect.right; + const remainingTags = tags.length - index - 1; + const needsPlusTag = remainingTags > 0; - // 如果不是最后一个标签,需要预留+n标签的空间 - const plusTagWidth = index < tags.length - 1 ? 35 : 0; // +n标签大约35px宽度 + // 精确计算:当前标签右边 + gap + (需要 "+n" 的话就加上 "+n" 宽度) + const totalWidthNeeded = tagRight + (needsPlusTag ? gap + plusWidth : 0); - if (totalWidth + tagWidth + plusTagWidth <= containerWidth) { - totalWidth += tagWidth; + if (totalWidthNeeded <= containerRight - 5) { visibleCount++; } else { - // 如果当前标签放不下,且已经有可见标签,则停止 - if (visibleCount > 0) return; - // 如果是第一个标签就放不下,至少显示一个 - if (index === 0) { - totalWidth += tagWidth; - visibleCount = 1; - } + return false; } }); - document.body.removeChild(tempDiv); - setVisibleTags(tags.slice(0, visibleCount)); setHiddenTags(tags.slice(visibleCount)); }; - // 延迟执行以确保DOM已渲染 - const timer = setTimeout(calculateVisibleTags, 0); + // 多次尝试计算,确保 DOM 渲染完成 + const timers = [ + setTimeout(calculateVisibleTags, 0), + setTimeout(calculateVisibleTags, 50), + setTimeout(calculateVisibleTags, 100), + ]; - // 监听窗口大小变化 const handleResize = () => { calculateVisibleTags(); }; @@ -126,7 +134,7 @@ const TagsRenderer = ({ tags }: { tags?: Array }) => { window.addEventListener("resize", handleResize); return () => { - clearTimeout(timer); + timers.forEach(t => clearTimeout(t)); window.removeEventListener("resize", handleResize); }; }, [tags]); @@ -154,7 +162,7 @@ const TagsRenderer = ({ tags }: { tags?: Array }) => { ); return ( -
+
{visibleTags.map((tag, index) => ( }) => { ? undefined : { background: tag.background, color: tag.color } } + className="shrink-0" > {typeof tag === "string" ? tag : (tag.label ? tag.label : tag.name)} @@ -175,7 +184,7 @@ const TagsRenderer = ({ tags }: { tags?: Array }) => { trigger="hover" placement="topLeft" > - + +{hiddenTags.length} @@ -246,19 +255,6 @@ function CardView(props: CardViewProps) { > {item?.name} - {(item?.tags?.length ?? 0) > 0 && - item.tags[0] && - typeof item.tags[0] !== "string" && ( - - {item.tags[0].label ? item.tags[0].label : item.tags[0].name} - - )} {item?.status && (
@@ -294,25 +290,17 @@ function CardView(props: CardViewProps) {
{/* Tags */} - - typeof tag === "string" || index !== 0 - ) - : [] - } - /> + {/* Description */} -

+

{item?.description}

{/* Statistics */} -
+
{item?.statistics?.map((stat, idx) => (
diff --git a/frontend/src/components/DetailHeader.tsx b/frontend/src/components/DetailHeader.tsx index 385b393f9..24a69642f 100644 --- a/frontend/src/components/DetailHeader.tsx +++ b/frontend/src/components/DetailHeader.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Database } from "lucide-react"; import { Card, Button, Tag, Tooltip, Popconfirm } from "antd"; import type { ItemType } from "antd/es/menu/interface"; @@ -24,11 +24,11 @@ interface OperationItem { interface TagConfig { showAdd: boolean; - tags: { id: number; name: string; color: string }[]; + tags: Array<{ id: number; name: string; color: string } | string>; onFetchTags?: () => Promise<{ data: { id: number; name: string; color: string }[]; }>; - onAddTag?: (tag: { id: number; name: string; color: string }) => void; + onAddTag?: (tag: string) => void; onCreateAndTag?: (tagName: string) => void; } interface DetailHeaderProps { @@ -38,6 +38,126 @@ interface DetailHeaderProps { tagConfig?: TagConfig; } +// 标签单行渲染组件 +const TagsInline = ({ tags }: { tags: Array<{ id: number; name: string; color: string } | string> }) => { + const [visibleTags, setVisibleTags] = useState([]); + const [hiddenCount, setHiddenCount] = useState(0); + const containerRef = useRef(null); + + useEffect(() => { + if (!tags || tags.length === 0) return; + + const calculateVisibleTags = () => { + if (!containerRef.current) return; + + const container = containerRef.current; + + // 创建一个隐藏的测量容器 + const measureContainer = document.createElement("div"); + measureContainer.style.position = "absolute"; + measureContainer.style.visibility = "hidden"; + measureContainer.style.pointerEvents = "none"; + measureContainer.style.top = "0"; + measureContainer.style.left = "0"; + measureContainer.style.display = "inline-flex"; + measureContainer.style.alignItems = "center"; + measureContainer.style.gap = "4px"; + measureContainer.style.whiteSpace = "nowrap"; + measureContainer.style.flexWrap = "nowrap"; + measureContainer.style.zIndex = "-1"; + + // 创建 "+n" 标签来测量 + const plusTag = document.createElement("span"); + plusTag.className = "ant-tag ant-tag-default cursor-pointer bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200"; + plusTag.textContent = "+99"; + measureContainer.appendChild(plusTag); + const plusWidth = plusTag.offsetWidth; + + // 暂时插入到 DOM 中测量 + if (container.parentElement) { + container.parentElement.style.position = "relative"; + container.parentElement.appendChild(measureContainer); + + const containerWidth = container.offsetWidth; + const availableWidth = containerWidth - 8; // 安全边距 + + let visibleCount = 0; + + tags.forEach((tag, index) => { + const tagEl = document.createElement("span"); + tagEl.className = "ant-tag ant-tag-default shrink-0"; + const tagName = typeof tag === "string" ? tag : tag.name; + const tagColor = typeof tag === "string" ? undefined : tag.color; + if (tagColor) tagEl.style.color = tagColor; + tagEl.textContent = tagName; + measureContainer.appendChild(tagEl); + + // 测量当前容器宽度 + const currentWidth = measureContainer.offsetWidth; + const needsPlus = index < tags.length - 1; + const targetWidth = availableWidth - (needsPlus ? plusWidth : 0); + + if (currentWidth <= targetWidth) { + visibleCount++; + } else { + // 移除这个标签,因为它放不下 + measureContainer.removeChild(tagEl); + return false; // 停止循环 + } + + return true; + }); + + // 移除测量容器 + container.parentElement.removeChild(measureContainer); + + setVisibleTags(tags.slice(0, visibleCount)); + setHiddenCount(tags.length - visibleCount); + } + }; + + 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; + + return ( +
+ {visibleTags.map((tag, index) => { + const tagName = typeof tag === "string" ? tag : tag.name; + const tagColor = typeof tag === "string" ? undefined : tag.color; + return ( + + {tagName} + + ); + })} + {hiddenCount > 0 && ( + + + +{hiddenCount} + + + )} +
+ ); +}; + function DetailHeader({ data = {} as T, statistics, @@ -62,11 +182,11 @@ function DetailHeader({ )}
-
-
-

{(data as any)?.name}

+
+
+

{(data as any)?.name}

{(data as any)?.status && ( - +
{(data as any).status?.icon && {(data as any).status?.icon}} {(data as any).status?.label} @@ -74,27 +194,23 @@ function DetailHeader({ )}
- {(data as any)?.tags && ( -
- {(data as any)?.tags?.map((tag: any) => ( - - {tag.name} - - ))} - {tagConfig?.showAdd && ( - - )} -
- )} -

{(data as any)?.description}

+
+ {(data as any)?.tags && (data as any)?.tags?.length > 0 && ( + + )} + {tagConfig?.showAdd && ( + + )} +
+

{(data as any)?.description}

{statistics.map((stat: any) => ( -
+
{stat.icon} {stat.value}
diff --git a/frontend/src/components/business/TagManagement.tsx b/frontend/src/components/business/TagManagement.tsx index 51c55bcb1..56e621c54 100644 --- a/frontend/src/components/business/TagManagement.tsx +++ b/frontend/src/components/business/TagManagement.tsx @@ -7,14 +7,14 @@ import { useTranslation } from "react-i18next"; interface CustomTagProps { isEditable?: boolean; - tag: { id: number; name: string }; - editingTag?: string | null; + tag: TagItem; + editingTag?: TagItem | null; editingTagValue?: string; - setEditingTag?: React.Dispatch>; + setEditingTag?: React.Dispatch>; setEditingTagValue?: React.Dispatch>; - handleEditTag?: (tag: { id: number; name: string }, value: string) => void; - handleCancelEdit?: (tag: { id: number; name: string }) => void; - handleDeleteTag?: (tag: { id: number; name: string }) => void; + handleEditTag?: (tag: TagItem, value: string) => void; + handleCancelEdit?: (tag: TagItem) => void; + handleDeleteTag?: (tag: TagItem) => void; } function CustomTag({ @@ -99,17 +99,17 @@ const TagManager: React.FC = ({ onDelete, onUpdate, }: { - onFetch: () => Promise; - onCreate: (tag: Pick) => Promise<{ ok: boolean }>; - onDelete: (tagId: number) => Promise<{ ok: boolean }>; - onUpdate: (tag: TagItem) => Promise<{ ok: boolean }>; + onFetch: (params?: any) => Promise<{ data: TagItem[] }>; + onCreate: (tag: Pick) => Promise<{ data: TagItem }>; + onDelete: (ids: string) => Promise; + onUpdate: (tag: TagItem) => Promise<{ data: TagItem }>; }) => { const [showTagManager, setShowTagManager] = useState(false); const { message } = App.useApp(); const { t } = useTranslation(); - const [tags, setTags] = useState<{ id: number; name: string }[]>([]); + const [tags, setTags] = useState([]); const [newTag, setNewTag] = useState(""); - const [editingTag, setEditingTag] = useState(null); + const [editingTag, setEditingTag] = useState(null); const [editingTagValue, setEditingTagValue] = useState(""); // 获取标签列表 @@ -173,7 +173,7 @@ const TagManager: React.FC = ({ } }; - const handleCancelEdit = (tag: string) => { + const handleCancelEdit = (tag: TagItem) => { setEditingTag(null); setEditingTagValue(""); }; diff --git a/frontend/src/pages/DataManagement/Create/EditDataset.tsx b/frontend/src/pages/DataManagement/Create/EditDataset.tsx index 8d6eeb3ab..c349fa9a8 100644 --- a/frontend/src/pages/DataManagement/Create/EditDataset.tsx +++ b/frontend/src/pages/DataManagement/Create/EditDataset.tsx @@ -37,7 +37,9 @@ export default function EditDataset({ const updatedDataset = { ...newData, type: newData.type, - tags: newData.tags.map((tag) => tag.name) || [], + tags: (newData.tags || []).map((tag) => + typeof tag === "string" ? tag : tag.name + ), }; setNewDataset(updatedDataset); form.setFieldsValue(updatedDataset); diff --git a/frontend/src/pages/DataManagement/Detail/DatasetDetail.tsx b/frontend/src/pages/DataManagement/Detail/DatasetDetail.tsx index a7de6422d..7e30eb07a 100644 --- a/frontend/src/pages/DataManagement/Detail/DatasetDetail.tsx +++ b/frontend/src/pages/DataManagement/Detail/DatasetDetail.tsx @@ -221,15 +221,21 @@ export default function DatasetDetail() { onCreateAndTag: async (tagName) => { const res = await createDatasetTagUsingPost({ name: tagName }); if (res.data) { + const currentTags = dataset.tags.map((tag) => + typeof tag === "string" ? tag : tag.name + ); await updateDatasetByIdUsingPut(dataset.id, { - tags: [...dataset.tags.map((tag) => tag.name), res.data.name], + tags: [...currentTags, res.data.name], }); handleRefresh(); } }, - onAddTag: async (tag) => { + onAddTag: async (tagName) => { + const currentTags = dataset.tags.map((tag) => + typeof tag === "string" ? tag : tag.name + ); const res = await updateDatasetByIdUsingPut(dataset.id, { - tags: [...dataset.tags.map((tag) => tag.name), tag], + tags: [...currentTags, tagName], }); if (res.data) { handleRefresh(); diff --git a/frontend/src/pages/DataManagement/Home/DataManagement.tsx b/frontend/src/pages/DataManagement/Home/DataManagement.tsx index 1b47b80c5..8c92bf343 100644 --- a/frontend/src/pages/DataManagement/Home/DataManagement.tsx +++ b/frontend/src/pages/DataManagement/Home/DataManagement.tsx @@ -331,10 +331,34 @@ export default function DatasetManagementPage() {
{/* tasks */} deleteDatasetTagUsingDelete({ ids })} - onUpdate={updateDatasetTagUsingPut} - onFetch={queryDatasetTagsUsingGet} + onCreate={async (tag) => { + const result = await createDatasetTagUsingPost(tag); + if (result.data) { + const { data } = await queryDatasetTagsUsingGet(); + setTags(data.map((tag) => tag.name)); + } + return result; + }} + onDelete={async (ids) => { + const result = await deleteDatasetTagUsingDelete({ ids }); + if (result) { + const { data } = await queryDatasetTagsUsingGet(); + setTags(data.map((tag) => tag.name)); + } + return result; + }} + onUpdate={async (tag) => { + const result = await updateDatasetTagUsingPut(tag); + if (result.data) { + const { data } = await queryDatasetTagsUsingGet(); + setTags(data.map((tag) => tag.name)); + } + return result; + }} + onFetch={async () => { + const result = await queryDatasetTagsUsingGet(); + return { data: result.data || [] }; + }} />