You've already forked DataMate
init datamate
This commit is contained in:
129
frontend/src/components/AddTagPopover.tsx
Normal file
129
frontend/src/components/AddTagPopover.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Button, Input, Popover, theme, Tag } from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface AddTagPopoverProps {
|
||||
tags: Tag[];
|
||||
onFetchTags?: () => Promise<Tag[]>;
|
||||
onAddTag?: (tag: Tag) => void;
|
||||
onCreateAndTag?: (tagName: string) => void;
|
||||
}
|
||||
|
||||
export default function AddTagPopover({
|
||||
tags,
|
||||
onFetchTags,
|
||||
onAddTag,
|
||||
onCreateAndTag,
|
||||
}: AddTagPopoverProps) {
|
||||
const { token } = theme.useToken();
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
|
||||
const [newTag, setNewTag] = useState("");
|
||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||
|
||||
const tagsSet = useMemo(() => new Set(tags.map((tag) => tag.id)), [tags]);
|
||||
|
||||
const fetchTags = async () => {
|
||||
if (onFetchTags && showPopover) {
|
||||
const data = await onFetchTags?.();
|
||||
setAllTags(data || []);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
fetchTags();
|
||||
}, [showPopover]);
|
||||
|
||||
const availableTags = useMemo(() => {
|
||||
return allTags.filter((tag) => !tagsSet.has(tag.id));
|
||||
}, [allTags, tagsSet]);
|
||||
|
||||
const handleCreateAndAddTag = () => {
|
||||
if (newTag.trim()) {
|
||||
onCreateAndTag?.(newTag.trim());
|
||||
setNewTag("");
|
||||
}
|
||||
|
||||
setShowPopover(false);
|
||||
};
|
||||
|
||||
const tagPlusStyle: React.CSSProperties = {
|
||||
height: 22,
|
||||
background: token.colorBgContainer,
|
||||
borderStyle: "dashed",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
open={showPopover}
|
||||
trigger="click"
|
||||
placement="bottom"
|
||||
content={
|
||||
<div className="space-y-4 w-[300px]">
|
||||
<h4 className="font-medium border-b pb-2 border-gray-100">
|
||||
添加标签
|
||||
</h4>
|
||||
{/* Available Tags */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-sm">选择现有标签</h5>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||
{availableTags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="h-7 w-full justify-start text-xs cursor-pointer flex items-center px-2 rounded hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
onAddTag?.(tag.name);
|
||||
setShowPopover(false);
|
||||
}}
|
||||
>
|
||||
<PlusOutlined className="w-3 h-3 mr-1" />
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create New Tag */}
|
||||
<div className="space-y-2 border-t border-gray-100 pt-3">
|
||||
<h5 className="text-sm">创建新标签</h5>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="输入新标签名称..."
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleCreateAndAddTag()}
|
||||
disabled={!newTag.trim()}
|
||||
type="primary"
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button block onClick={() => setShowPopover(false)}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag
|
||||
style={tagPlusStyle}
|
||||
icon={<PlusOutlined />}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setShowPopover(true)}
|
||||
>
|
||||
添加标签
|
||||
</Tag>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
291
frontend/src/components/CardView.tsx
Normal file
291
frontend/src/components/CardView.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Tag, Pagination, Dropdown, Tooltip, Empty, Popover } from "antd";
|
||||
import {
|
||||
EllipsisOutlined,
|
||||
ClockCircleOutlined,
|
||||
StarFilled,
|
||||
} from "@ant-design/icons";
|
||||
import type { ItemType } from "antd/es/menu/interface";
|
||||
import { formatDateTime } from "@/utils/unit";
|
||||
|
||||
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;
|
||||
icon?: React.JSX.Element;
|
||||
onClick?: (item: T) => void;
|
||||
}[]
|
||||
| ((item: T) => ItemType[]);
|
||||
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, 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-1 flex flex-col overflow-auto">
|
||||
<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 border-gray-100 rounded-lg p-4 bg-white hover:shadow-lg transition-shadow duration-200"
|
||||
>
|
||||
<div className="flex flex-col space-y-4 h-full">
|
||||
{/* 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 ${
|
||||
item?.iconColor ||
|
||||
"bg-gradient-to-br from-blue-100 to-blue-200"
|
||||
} rounded-lg flex items-center justify-center`}
|
||||
>
|
||||
{item?.icon}
|
||||
</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 ${
|
||||
onView ? "cursor-pointer hover:text-blue-600" : ""
|
||||
}`}
|
||||
onClick={() => onView?.(item)}
|
||||
>
|
||||
{item?.name}
|
||||
</h3>
|
||||
{item?.status && (
|
||||
<Tag color={item?.status?.color}>
|
||||
<div className="flex items-center gap-2 text-xs py-0.5">
|
||||
<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">
|
||||
{stat?.label}:
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900">
|
||||
{stat?.value}
|
||||
</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 && (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
items: ops(item),
|
||||
onClick: ({ key }) => {
|
||||
const operation = ops(item).find(
|
||||
(op) => op.key === key
|
||||
);
|
||||
if (operation?.onClick) {
|
||||
operation.onClick(item);
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="cursor-pointer">
|
||||
<EllipsisOutlined style={{ fontSize: 24 }} />
|
||||
</div>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end mt-6">
|
||||
<Pagination {...pagination} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CardView;
|
||||
137
frontend/src/components/DetailHeader.tsx
Normal file
137
frontend/src/components/DetailHeader.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from "react";
|
||||
import { Database } from "lucide-react";
|
||||
import { Card, Dropdown, Button, Tag, Tooltip } from "antd";
|
||||
import type { ItemType } from "antd/es/menu/interface";
|
||||
import AddTagPopover from "./AddTagPopover";
|
||||
|
||||
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,
|
||||
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-xl flex items-center justify-center shadow-lg ${
|
||||
data?.iconColor
|
||||
? data.iconColor
|
||||
: "bg-gradient-to-br from-blue-100 to-blue-200"
|
||||
}`}
|
||||
>
|
||||
{data?.icon || <Database className="w-8 h-8" />}
|
||||
</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">
|
||||
<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 (
|
||||
<Dropdown
|
||||
key={op.key}
|
||||
menu={{
|
||||
items: op.items as ItemType[],
|
||||
onClick: op.onMenuClick,
|
||||
}}
|
||||
>
|
||||
<Tooltip title={op.label}>
|
||||
<Button icon={op.icon} />
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip key={op.key} title={op.label}>
|
||||
<Button
|
||||
key={op.key}
|
||||
onClick={op.onClick}
|
||||
className={
|
||||
op.danger
|
||||
? "text-red-600 border-red-200 bg-transparent"
|
||||
: ""
|
||||
}
|
||||
icon={op.icon}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailHeader;
|
||||
27
frontend/src/components/DevelopmentInProgress.tsx
Normal file
27
frontend/src/components/DevelopmentInProgress.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Button } from "antd";
|
||||
|
||||
const DevelopmentInProgress = ({ showHome = true }) => {
|
||||
return (
|
||||
<div className="mt-40 flex flex-col items-center justify-center">
|
||||
<div className="hero-icon">🚧</div>
|
||||
<h1 className="text-2xl font-bold">功能开发中</h1>
|
||||
<p className="mt-4">
|
||||
为了给您带来更好的体验,我们计划<b>2025.10.30</b>
|
||||
开放此功能
|
||||
</p>
|
||||
{showHome && (
|
||||
<Button
|
||||
type="primary"
|
||||
className="mt-6"
|
||||
onClick={() => {
|
||||
window.location.href = "/";
|
||||
}}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevelopmentInProgress;
|
||||
191
frontend/src/components/ErrorBoundary.tsx
Normal file
191
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { Component } from "react";
|
||||
import { Button, Modal } from "antd";
|
||||
|
||||
interface ErrorContextType {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: { componentStack: string } | null;
|
||||
}
|
||||
|
||||
const ErrorContext = React.createContext<ErrorContextType>({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
});
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: { componentStack: string } | null;
|
||||
errorTimestamp: string | null;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children?: React.ReactNode;
|
||||
onReset?: () => void;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
errorTimestamp: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: any) {
|
||||
// 更新 state 使下一次渲染能够显示降级 UI
|
||||
return {
|
||||
hasError: true,
|
||||
error: error,
|
||||
errorTimestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// 错误统计
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
hasError: true,
|
||||
});
|
||||
|
||||
// 在实际应用中,这里可以集成错误报告服务
|
||||
this.logErrorToService(error, errorInfo);
|
||||
|
||||
// 开发环境下在控制台显示详细错误
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("ErrorBoundary 捕获到错误:", error);
|
||||
console.error("错误详情:", errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
logErrorToService = (error: Error, errorInfo: React.ErrorInfo) => {
|
||||
// 这里可以集成 Sentry、LogRocket 等错误监控服务
|
||||
const errorData = {
|
||||
error: error.toString(),
|
||||
errorInfo: errorInfo.componentStack,
|
||||
timestamp: this.state.errorTimestamp,
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
};
|
||||
|
||||
// 模拟发送错误日志
|
||||
console.log("发送错误日志到监控服务:", errorData);
|
||||
|
||||
// 实际使用时取消注释并配置您的错误监控服务
|
||||
/*
|
||||
if (window.Sentry) {
|
||||
window.Sentry.captureException(error, { extra: errorInfo });
|
||||
}
|
||||
*/
|
||||
};
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
errorTimestamp: null,
|
||||
});
|
||||
|
||||
// 可选:重新加载页面或执行其他恢复操作
|
||||
if (this.props.onReset) {
|
||||
this.props.onReset();
|
||||
}
|
||||
};
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
renderErrorDetails = () => {
|
||||
const { error, errorInfo } = this.state;
|
||||
|
||||
if (!this.props.showDetails) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 p-4 mt-4 text-left rounded">
|
||||
<div className="mt-2">
|
||||
<strong>错误信息:</strong>
|
||||
<pre className="bg-gray-600 px-4 py-2 rounded text-white overflow-auto">
|
||||
{error?.toString()}
|
||||
</pre>
|
||||
</div>
|
||||
{errorInfo && (
|
||||
<div className="mt-2">
|
||||
<strong>组件堆栈:</strong>
|
||||
<pre className="bg-gray-600 max-h-100 px-4 py-2 rounded text-white overflow-auto">
|
||||
{errorInfo.componentStack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Modal visible width={1000} footer={null} closable={false}>
|
||||
<div className="text-center p-6">
|
||||
<div className="text-3xl">⚠️</div>
|
||||
<h1 className="text-xl p-2">出了点问题</h1>
|
||||
<p className="text-sm text-gray-400">应用程序遇到了意外错误。</p>
|
||||
|
||||
<div className="flex justify-center gap-4 my-4">
|
||||
<Button onClick={this.handleReload}>刷新页面</Button>
|
||||
<Button type="primary" onClick={this.handleGoHome}>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{this.renderErrorDetails()}
|
||||
|
||||
<div className="mt-4 border-t border-gray-100 pt-4 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
如果问题持续存在,请联系技术支持
|
||||
</p>
|
||||
<small className="text-xs text-gray-400">
|
||||
错误 ID: {this.state.errorTimestamp}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorContext.Provider
|
||||
value={{
|
||||
hasError: this.state.hasError,
|
||||
error: this.state.error,
|
||||
errorInfo: this.state.errorInfo,
|
||||
}}
|
||||
>
|
||||
{this.props.children}
|
||||
</ErrorContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function withErrorBoundary(
|
||||
Component: React.ComponentType
|
||||
): React.ComponentType {
|
||||
return (props) => (
|
||||
<ErrorBoundary showDetails={process.env.NODE_ENV === "development"}>
|
||||
<Component {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/RadioCard.tsx
Normal file
70
frontend/src/components/RadioCard.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import { Card } from "antd";
|
||||
|
||||
interface RadioCardOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: SVGAElement | React.FC<React.SVGProps<SVGElement>>;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface RadioCardProps {
|
||||
options: RadioCardOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const RadioCard: React.FC<RadioCardProps> = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 ${
|
||||
className || ""
|
||||
}`}
|
||||
style={{ gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))" }}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="border border-gray-200 rounded-lg hover:shadow-lg p-4 text-center"
|
||||
style={{
|
||||
borderColor: value === option.value ? "#1677ff" : undefined,
|
||||
background: value === option.value ? "#e6f7ff" : undefined,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
<option.icon
|
||||
className={`w-8 h-8 mx-auto mb-2 ${
|
||||
value === option.value ? "text-blue-500" : "text-gray-400"
|
||||
}`}
|
||||
/>
|
||||
<h3
|
||||
className={`font-medium text-sm mb-1 ${
|
||||
value === option.value ? "text-blue-500" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</h3>
|
||||
{option.description && (
|
||||
<div
|
||||
className={`text-xs ${
|
||||
value === option.value ? "text-blue-500" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RadioCard;
|
||||
239
frontend/src/components/SearchControls.tsx
Normal file
239
frontend/src/components/SearchControls.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { Input, Button, Select, Tag, Segmented, DatePicker } from "antd";
|
||||
import {
|
||||
BarsOutlined,
|
||||
AppstoreOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface FilterOption {
|
||||
key: string;
|
||||
label: string;
|
||||
mode?: "tags" | "multiple";
|
||||
options: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
interface SearchControlsProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
|
||||
// Filter props
|
||||
filters?: FilterOption[];
|
||||
selectedFilters?: Record<string, string[]>;
|
||||
onFiltersChange?: (filters: Record<string, string[]>) => void;
|
||||
onClearFilters?: () => void;
|
||||
|
||||
// Date range props
|
||||
dateRange?: [Date | null, Date | null] | null;
|
||||
onDateChange?: (dates: [Date | null, Date | null] | null) => void;
|
||||
|
||||
// Reload props
|
||||
onReload?: () => void;
|
||||
|
||||
// View props
|
||||
viewMode?: "card" | "list";
|
||||
onViewModeChange?: (mode: "card" | "list") => void;
|
||||
|
||||
// Control visibility
|
||||
showFilters?: boolean;
|
||||
showSort?: boolean;
|
||||
showViewToggle?: boolean;
|
||||
showReload?: boolean;
|
||||
showDatePicker?: boolean;
|
||||
|
||||
// Styling
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SearchControls({
|
||||
viewMode,
|
||||
className,
|
||||
searchTerm,
|
||||
showFilters = true,
|
||||
showViewToggle = true,
|
||||
searchPlaceholder = "搜索...",
|
||||
filters = [],
|
||||
dateRange,
|
||||
showDatePicker = false,
|
||||
showReload = true,
|
||||
onReload,
|
||||
onDateChange,
|
||||
onSearchChange,
|
||||
onFiltersChange,
|
||||
onViewModeChange,
|
||||
onClearFilters,
|
||||
}: SearchControlsProps) {
|
||||
const [selectedFilters, setSelectedFilters] = useState<{
|
||||
[key: string]: string[];
|
||||
}>({});
|
||||
|
||||
const filtersMap: Record<string, FilterOption> = filters.reduce(
|
||||
(prev, cur) => ({ ...prev, [cur.key]: cur }),
|
||||
{}
|
||||
);
|
||||
|
||||
// select change
|
||||
const handleFilterChange = (filterKey: string, value: string) => {
|
||||
const filteredValues = {
|
||||
...selectedFilters,
|
||||
[filterKey]: !value ? [] : [value],
|
||||
};
|
||||
setSelectedFilters(filteredValues);
|
||||
};
|
||||
|
||||
// 清除已选筛选
|
||||
const handleClearFilter = (filterKey: string, value: string | string[]) => {
|
||||
const isMultiple = filtersMap[filterKey]?.mode === "multiple";
|
||||
if (!isMultiple) {
|
||||
setSelectedFilters({
|
||||
...selectedFilters,
|
||||
[filterKey]: [],
|
||||
});
|
||||
} else {
|
||||
const currentValues = selectedFilters[filterKey]?.[0] || [];
|
||||
const newValues = currentValues.filter((v) => v !== value);
|
||||
setSelectedFilters({
|
||||
...selectedFilters,
|
||||
[filterKey]: [newValues],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
setSelectedFilters({});
|
||||
onClearFilters?.();
|
||||
};
|
||||
|
||||
const hasActiveFilters = Object.values(selectedFilters).some(
|
||||
(values) => values?.[0]?.length > 0
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(selectedFilters).length === 0) return;
|
||||
onFiltersChange?.(selectedFilters);
|
||||
}, [selectedFilters]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
{/* Left side - Search and Filters */}
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
allowClear
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
prefix={<SearchOutlined className="w-4 h-4 text-gray-400" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && filters.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{filters.map((filter: FilterOption) => (
|
||||
<Select
|
||||
maxTagCount="responsive"
|
||||
mode={filter.mode}
|
||||
key={filter.key}
|
||||
placeholder={filter.label}
|
||||
value={selectedFilters[filter.key]?.[0] || undefined}
|
||||
onChange={(value) => handleFilterChange(filter.key, value)}
|
||||
style={{ width: 144 }}
|
||||
allowClear
|
||||
>
|
||||
{filter.options.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDatePicker && (
|
||||
<DatePicker.RangePicker
|
||||
value={dateRange as any}
|
||||
onChange={onDateChange}
|
||||
style={{ width: 260 }}
|
||||
allowClear
|
||||
placeholder={["开始时间", "结束时间"]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2">
|
||||
{showViewToggle && onViewModeChange && (
|
||||
<Segmented
|
||||
options={[
|
||||
{ value: "list", icon: <BarsOutlined /> },
|
||||
{ value: "card", icon: <AppstoreOutlined /> },
|
||||
]}
|
||||
value={viewMode}
|
||||
onChange={(value) => onViewModeChange(value as "list" | "card")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showReload && (
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => onReload?.()}
|
||||
></Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Display */}
|
||||
{hasActiveFilters && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap flex-1">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
已选筛选:
|
||||
</span>
|
||||
{Object.entries(selectedFilters).map(([filterKey, values]) =>
|
||||
values.map((value) => {
|
||||
const filter = filtersMap[filterKey];
|
||||
|
||||
const getLabeledValue = (item: string) => {
|
||||
const option = filter?.options.find(
|
||||
(o) => o.value === item
|
||||
);
|
||||
return (
|
||||
<Tag
|
||||
key={`${filterKey}-${item}`}
|
||||
closable
|
||||
onClose={() => handleClearFilter(filterKey, item)}
|
||||
color="blue"
|
||||
>
|
||||
{filter?.label}: {option?.label || item}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
return Array.isArray(value)
|
||||
? value.map((item) => getLabeledValue(item))
|
||||
: getLabeledValue(value);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clear all filters button on the right */}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={handleClearAllFilters}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
清除全部
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
frontend/src/components/TagList.tsx
Normal file
149
frontend/src/components/TagList.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import type { InputRef } from "antd";
|
||||
import { Flex, Input, Tag, theme, Tooltip } from "antd";
|
||||
|
||||
const tagInputStyle: React.CSSProperties = {
|
||||
width: 64,
|
||||
height: 22,
|
||||
marginInlineEnd: 8,
|
||||
verticalAlign: "top",
|
||||
};
|
||||
|
||||
interface TagListProps {
|
||||
tags: string[];
|
||||
setTags: (tags: string[]) => void;
|
||||
onDelete?: (tag: string) => void;
|
||||
onAdd?: (tag: string) => void;
|
||||
onEdit?: (oldTag: string, newTag: string) => void;
|
||||
}
|
||||
|
||||
const TagList: React.FC<TagListProps> = ({
|
||||
tags,
|
||||
setTags,
|
||||
onDelete,
|
||||
onAdd,
|
||||
onEdit,
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
const [inputVisible, setInputVisible] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [editInputIndex, setEditInputIndex] = useState(-1);
|
||||
const [editInputValue, setEditInputValue] = useState("");
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const editInputRef = useRef<InputRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputVisible) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [inputVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
editInputRef.current?.focus();
|
||||
}, [editInputValue]);
|
||||
|
||||
const handleClose = (removedTag: string) => {
|
||||
const newTags = tags.filter((tag) => tag !== removedTag);
|
||||
setTags(newTags);
|
||||
onDelete?.(removedTag);
|
||||
};
|
||||
|
||||
const showInput = () => {
|
||||
setInputVisible(true);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleInputConfirm = () => {
|
||||
if (inputValue && !tags.includes(inputValue)) {
|
||||
setTags([...tags, inputValue]);
|
||||
onAdd?.(inputValue);
|
||||
}
|
||||
setInputVisible(false);
|
||||
setInputValue("");
|
||||
};
|
||||
|
||||
const handleEditInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEditInputValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleEditInputConfirm = () => {
|
||||
const newTags = [...tags];
|
||||
newTags[editInputIndex] = editInputValue;
|
||||
setTags(newTags);
|
||||
onEdit?.(tags[editInputIndex], editInputValue);
|
||||
setEditInputIndex(-1);
|
||||
setEditInputValue("");
|
||||
};
|
||||
|
||||
const tagPlusStyle: React.CSSProperties = {
|
||||
height: 22,
|
||||
background: token.colorBgContainer,
|
||||
borderStyle: "dashed",
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap="4px 0" wrap>
|
||||
{tags.map<React.ReactNode>((tag, index) => {
|
||||
if (editInputIndex === index) {
|
||||
return (
|
||||
<Input
|
||||
ref={editInputRef}
|
||||
key={tag}
|
||||
size="small"
|
||||
style={tagInputStyle}
|
||||
value={editInputValue}
|
||||
onChange={handleEditInputChange}
|
||||
onBlur={handleEditInputConfirm}
|
||||
onPressEnter={handleEditInputConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isLongTag = tag.length > 20;
|
||||
const tagElem = (
|
||||
<Tag key={tag} onClose={() => handleClose(tag)} closable>
|
||||
<span
|
||||
onDoubleClick={(e) => {
|
||||
if (index !== 0) {
|
||||
setEditInputIndex(index);
|
||||
setEditInputValue(tag);
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLongTag ? `${tag.slice(0, 20)}...` : tag}
|
||||
</span>
|
||||
</Tag>
|
||||
);
|
||||
return isLongTag ? (
|
||||
<Tooltip title={tag} key={tag}>
|
||||
{tagElem}
|
||||
</Tooltip>
|
||||
) : (
|
||||
tagElem
|
||||
);
|
||||
})}
|
||||
{inputVisible ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
size="small"
|
||||
style={tagInputStyle}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputConfirm}
|
||||
onPressEnter={handleInputConfirm}
|
||||
/>
|
||||
) : (
|
||||
<Tag style={tagPlusStyle} icon={<PlusOutlined />} onClick={showInput}>
|
||||
新增标签
|
||||
</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagList;
|
||||
271
frontend/src/components/TagManagement.tsx
Normal file
271
frontend/src/components/TagManagement.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Drawer, Input, Button, App } from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { Edit, Save, TagIcon, X, Trash } from "lucide-react";
|
||||
import { TagItem } from "@/pages/DataManagement/dataset.model";
|
||||
|
||||
interface CustomTagProps {
|
||||
isEditable?: boolean;
|
||||
tag: { id: number; name: string };
|
||||
editingTag?: string | null;
|
||||
editingTagValue?: string;
|
||||
setEditingTag?: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setEditingTagValue?: React.Dispatch<React.SetStateAction<string>>;
|
||||
handleEditTag?: (tag: { id: number; name: string }, value: string) => void;
|
||||
handleCancelEdit?: (tag: { id: number; name: string }) => void;
|
||||
handleDeleteTag?: (tag: { id: number; name: string }) => void;
|
||||
}
|
||||
|
||||
function CustomTag({
|
||||
isEditable = false,
|
||||
tag,
|
||||
editingTag,
|
||||
editingTagValue,
|
||||
setEditingTag,
|
||||
setEditingTagValue,
|
||||
handleEditTag,
|
||||
handleCancelEdit,
|
||||
handleDeleteTag,
|
||||
}: CustomTagProps) {
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between px-4 py-2 border border-gray-100 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
{editingTag?.id === tag.id ? (
|
||||
<div className="flex gap-2 flex-1">
|
||||
<Input
|
||||
value={editingTagValue}
|
||||
onChange={(e) => setEditingTagValue?.(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleEditTag?.(tag, editingTagValue);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditingTag?.(null);
|
||||
setEditingTagValue?.("");
|
||||
}
|
||||
}}
|
||||
className="h-6 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleEditTag(tag, editingTagValue)}
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<Save className="w-3 h-3" />}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => handleCancelEdit?.(tag)}
|
||||
icon={<X className="w-3 h-3" />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm">{tag.name}</span>
|
||||
{isEditable && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
onClick={() => {
|
||||
setEditingTag?.(tag);
|
||||
setEditingTagValue?.(tag.name);
|
||||
}}
|
||||
icon={<Edit className="w-3 h-3" />}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => handleDeleteTag?.(tag)}
|
||||
icon={<Trash className="w-3 h-3" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const mockPreparedTags = [
|
||||
{ id: "1", name: "重要" },
|
||||
{ id: "2", name: "待处理" },
|
||||
{ id: "3", name: "已完成" },
|
||||
{ id: "4", name: "审核中" },
|
||||
{ id: "5", name: "高优先级" },
|
||||
{ id: "6", name: "低优先级" },
|
||||
{ id: "7", name: "客户A" },
|
||||
{ id: "8", name: "客户B" },
|
||||
];
|
||||
|
||||
const TagManager: React.FC = ({
|
||||
onFetch,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
}: {
|
||||
onFetch: () => Promise<any>;
|
||||
onCreate: (tag: Pick<TagItem, "name">) => Promise<{ ok: boolean }>;
|
||||
onDelete: (tagId: number) => Promise<{ ok: boolean }>;
|
||||
onUpdate: (oldTagId: number, newTag: string) => Promise<{ ok: boolean }>;
|
||||
}) => {
|
||||
const [showTagManager, setShowTagManager] = useState(false);
|
||||
const { message } = App.useApp();
|
||||
const [tags, setTags] = useState<{ id: number; name: string }[]>([]);
|
||||
const [newTag, setNewTag] = useState("");
|
||||
const [editingTag, setEditingTag] = useState<string | null>(null);
|
||||
const [editingTagValue, setEditingTagValue] = useState("");
|
||||
|
||||
// 预置标签
|
||||
const [preparedTags, setPreparedTags] = useState(mockPreparedTags);
|
||||
|
||||
// 获取标签列表
|
||||
const fetchTags = async () => {
|
||||
if (!onFetch) return;
|
||||
try {
|
||||
const { data } = await onFetch?.();
|
||||
setTags(data || []);
|
||||
} catch (e) {
|
||||
message.error("获取标签失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const addTag = async (tag: string) => {
|
||||
try {
|
||||
await onCreate?.({
|
||||
name: tag,
|
||||
});
|
||||
fetchTags();
|
||||
message.success("标签添加成功");
|
||||
} catch (error) {
|
||||
message.error("添加标签失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 删除标签
|
||||
const deleteTag = async (tag: TagItem) => {
|
||||
try {
|
||||
await onDelete?.(tag.id);
|
||||
fetchTags();
|
||||
message.success("标签删除成功");
|
||||
} catch (error) {
|
||||
message.error("删除标签失败");
|
||||
}
|
||||
};
|
||||
|
||||
const updateTag = async (oldTag: TagItem, newTag: string) => {
|
||||
try {
|
||||
await onUpdate?.(oldTag.id, { ...oldTag, name: newTag });
|
||||
fetchTags();
|
||||
message.success("标签更新成功");
|
||||
} catch (error) {
|
||||
message.error("更新标签失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNewTag = () => {
|
||||
if (newTag.trim()) {
|
||||
addTag(newTag.trim());
|
||||
setNewTag("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTag = (tag: TagItem, value: string) => {
|
||||
if (value.trim()) {
|
||||
updateTag(tag, value.trim());
|
||||
setEditingTag(null);
|
||||
setEditingTagValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = (tag: string) => {
|
||||
setEditingTag(null);
|
||||
setEditingTagValue("");
|
||||
};
|
||||
|
||||
const handleDeleteTag = (tag: TagItem) => {
|
||||
deleteTag(tag);
|
||||
setEditingTag(null);
|
||||
setEditingTagValue("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showTagManager) fetchTags();
|
||||
}, [showTagManager]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon={<TagIcon className="w-4 h-4 mr-2" />}
|
||||
onClick={() => setShowTagManager(true)}
|
||||
>
|
||||
标签管理
|
||||
</Button>
|
||||
<Drawer
|
||||
open={showTagManager}
|
||||
onClose={() => setShowTagManager(false)}
|
||||
title="标签管理"
|
||||
width={500}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Add New Tag */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="输入标签名称..."
|
||||
value={newTag}
|
||||
allowClear
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
addTag(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleCreateNewTag}
|
||||
disabled={!newTag.trim()}
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="font-large font-bold w-full">预置标签</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{preparedTags.length > 0 &&
|
||||
preparedTags.map((tag) => <CustomTag key={tag.id} tag={tag} />)}
|
||||
</div>
|
||||
|
||||
<h2 className="font-large font-bold w-full">自定义标签</h2>
|
||||
<div className="grid grid-cols-2 gap-2 mt-4">
|
||||
{tags.map((tag) => (
|
||||
<CustomTag
|
||||
isEditable
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
editingTag={editingTag}
|
||||
editingTagValue={editingTagValue}
|
||||
setEditingTag={setEditingTag}
|
||||
setEditingTagValue={setEditingTagValue}
|
||||
handleEditTag={handleEditTag}
|
||||
handleCancelEdit={handleCancelEdit}
|
||||
handleDeleteTag={handleDeleteTag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagManager;
|
||||
162
frontend/src/components/TaskPopover.tsx
Normal file
162
frontend/src/components/TaskPopover.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Button, Popover, Progress } from "antd";
|
||||
import { Calendar, Clock, Play, Trash2, X } from "lucide-react";
|
||||
|
||||
interface TaskItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
scheduleConfig: {
|
||||
type: string;
|
||||
cronExpression?: string;
|
||||
executionCount?: number;
|
||||
maxExecutions?: number;
|
||||
};
|
||||
nextExecution?: string;
|
||||
importConfig: {
|
||||
source?: string;
|
||||
};
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function TaskPopover() {
|
||||
const tasks: TaskItem[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "导入客户数据",
|
||||
status: "importing",
|
||||
progress: 65,
|
||||
scheduleConfig: {
|
||||
type: "manual",
|
||||
},
|
||||
importConfig: {
|
||||
source: "local",
|
||||
},
|
||||
createdAt: "2025-07-29 14:23",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "定时同步订单",
|
||||
status: "waiting",
|
||||
progress: 0,
|
||||
scheduleConfig: {
|
||||
type: "scheduled",
|
||||
cronExpression: "0 0 * * *",
|
||||
executionCount: 3,
|
||||
maxExecutions: 10,
|
||||
},
|
||||
nextExecution: "2025-07-31 00:00",
|
||||
importConfig: {
|
||||
source: "api",
|
||||
},
|
||||
createdAt: "2025-07-28 09:10",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "清理历史日志",
|
||||
status: "finished",
|
||||
progress: 100,
|
||||
scheduleConfig: {
|
||||
type: "manual",
|
||||
},
|
||||
importConfig: {
|
||||
source: "system",
|
||||
},
|
||||
createdAt: "2025-07-27 17:45",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Popover
|
||||
placement="topLeft"
|
||||
content={
|
||||
<div className="w-[500px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-900">近期任务</h3>
|
||||
<Button type="text" className="h-6 w-6 p-0">
|
||||
<X className="w-4 h-4 text-black-400 hover:text-gray-500" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Clock className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||||
<p className="text-sm">暂无创建任务</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-3 border border-gray-100 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm truncate flex-1">
|
||||
{task.name}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{task.status === "waiting" && (
|
||||
<Button
|
||||
className="h-6 w-6 p-0 text-blue-500 hover:text-blue-700"
|
||||
title="立即执行"
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button className="h-6 w-6 p-0 text-gray-400 hover:text-red-500">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.status === "importing" && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>导入进度</span>
|
||||
<span>{Math.round(task.progress)}%</span>
|
||||
</div>
|
||||
<Progress percent={task.progress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule Information */}
|
||||
{task.scheduleConfig.type === "scheduled" && (
|
||||
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span className="font-medium">定时任务</span>
|
||||
</div>
|
||||
<div>Cron: {task.scheduleConfig.cronExpression}</div>
|
||||
{task.nextExecution && (
|
||||
<div>下次执行: {task.nextExecution}</div>
|
||||
)}
|
||||
<div>
|
||||
执行次数: {task.scheduleConfig.executionCount || 0}/
|
||||
{task.scheduleConfig.maxExecutions || 10}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||
<span>
|
||||
{task.importConfig.source === "local"
|
||||
? "本地上传"
|
||||
: task.importConfig.source || "未知来源"}
|
||||
</span>
|
||||
<span>{task.createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button block>任务中心</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/TopLoadingBar.tsx
Normal file
69
frontend/src/components/TopLoadingBar.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const TopLoadingBar = () => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const intervalRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 监听全局事件
|
||||
const handleShow = () => {
|
||||
setIsVisible(true);
|
||||
setProgress(0);
|
||||
|
||||
// 清除可能存在的旧interval
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
// 模拟进度
|
||||
let currentProgress = 0;
|
||||
intervalRef.current = setInterval(() => {
|
||||
currentProgress += Math.random() * 10;
|
||||
if (currentProgress >= 90) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
setProgress(currentProgress);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleHide = () => {
|
||||
// 清除进度interval
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
setProgress(100);
|
||||
setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setProgress(0);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 添加全局事件监听器
|
||||
window.addEventListener("loading:show", handleShow);
|
||||
window.addEventListener("loading:hide", handleHide);
|
||||
|
||||
return () => {
|
||||
// 组件卸载时清理
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
window.removeEventListener("loading:show", handleShow);
|
||||
window.removeEventListener("loading:hide", handleHide);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="top-loading-bar">
|
||||
<div
|
||||
className="loading-bar-progress"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopLoadingBar;
|
||||
Reference in New Issue
Block a user