init datamate

This commit is contained in:
Dallas98
2025-10-21 23:00:48 +08:00
commit 1c97afed7d
692 changed files with 135442 additions and 0 deletions

View 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>
</>
);
}

View 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;

View 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;

View 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;

View 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>
);
}

View 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;

View 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>
);
}

View 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;

View 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;

View 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>
);
}

View 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;