重构消息通知弹窗
This commit is contained in:
parent
9275f5d4ae
commit
44ea1353b2
@ -1,29 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useImperativeHandle } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, Search, RotateCcw } from 'lucide-react';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './table';
|
||||
import { DataTablePagination } from './pagination';
|
||||
import { Button } from './button';
|
||||
import { Input } from './input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
|
||||
import type { Page } from '@/types/base';
|
||||
import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_NUM } from '@/utils/page';
|
||||
|
||||
// 列定义
|
||||
export interface ColumnDef<T> {
|
||||
/** 列标识 */
|
||||
key: string;
|
||||
/** 列标题 */
|
||||
title: React.ReactNode;
|
||||
/** 列宽度 */
|
||||
width?: string;
|
||||
/** 是否固定列 */
|
||||
sticky?: boolean;
|
||||
/** 自定义渲染 */
|
||||
render?: (value: any, record: T, index: number) => React.ReactNode;
|
||||
/** 数据字段路径,支持嵌套如 'user.name' */
|
||||
dataIndex?: string;
|
||||
/** 单元格类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 搜索字段定义
|
||||
export interface SearchFieldDef {
|
||||
key: string;
|
||||
type: 'input' | 'select';
|
||||
placeholder?: string;
|
||||
options?: { label: string; value: string | number }[];
|
||||
width?: string;
|
||||
/** 是否支持回车搜索(仅 input 类型) */
|
||||
enterSearch?: boolean;
|
||||
}
|
||||
|
||||
// 查询参数基础类型
|
||||
export interface BaseQuery {
|
||||
pageNum?: number;
|
||||
@ -33,47 +40,40 @@ export interface BaseQuery {
|
||||
|
||||
// 组件 Props
|
||||
export interface PaginatedTableProps<T, Q extends BaseQuery = BaseQuery> {
|
||||
/** 数据获取函数 */
|
||||
fetchFn: (query: Q) => Promise<Page<T>>;
|
||||
/** 列定义 */
|
||||
columns: ColumnDef<T>[];
|
||||
/** 查询参数(不含分页) */
|
||||
query?: Omit<Q, 'pageNum' | 'pageSize'>;
|
||||
/** 行唯一标识字段 */
|
||||
/** 搜索字段定义(简化模式) */
|
||||
searchFields?: SearchFieldDef[];
|
||||
/** 自定义搜索栏(完全自定义模式) */
|
||||
searchBar?: React.ReactNode;
|
||||
/** 工具栏(新建按钮等) */
|
||||
toolbar?: React.ReactNode;
|
||||
/** 初始查询参数 */
|
||||
initialQuery?: Omit<Q, 'pageNum' | 'pageSize'>;
|
||||
rowKey?: keyof T | ((record: T) => string | number);
|
||||
/** 表格最小宽度 */
|
||||
minWidth?: string;
|
||||
/** 初始页大小 */
|
||||
pageSize?: number;
|
||||
/** 空数据提示 */
|
||||
emptyText?: React.ReactNode;
|
||||
/** 加载中提示 */
|
||||
loadingText?: React.ReactNode;
|
||||
/** 是否在 query 变化时自动刷新 */
|
||||
autoRefresh?: boolean;
|
||||
/** 额外的依赖项,变化时触发刷新 */
|
||||
deps?: any[];
|
||||
/** 搜索栏和表格之间的间距 */
|
||||
searchGap?: string;
|
||||
}
|
||||
|
||||
// 暴露给外部的方法
|
||||
export interface PaginatedTableRef<T> {
|
||||
/** 刷新数据 */
|
||||
refresh: (showLoading?: boolean) => Promise<void>;
|
||||
/** 重置到第一页并刷新 */
|
||||
reset: () => void;
|
||||
/** 获取当前数据 */
|
||||
search: () => void;
|
||||
getData: () => Page<T> | null;
|
||||
/** 局部更新某条记录 */
|
||||
getQuery: () => Record<string, any>;
|
||||
updateRecord: (id: string | number, updates: Partial<T>) => void;
|
||||
}
|
||||
|
||||
// 获取嵌套属性值
|
||||
function getNestedValue(obj: any, path?: string): any {
|
||||
if (!path) return undefined;
|
||||
return path.split('.').reduce((acc, part) => acc?.[part], obj);
|
||||
}
|
||||
|
||||
// 获取行 key
|
||||
function getRowKey<T>(record: T, rowKey: keyof T | ((record: T) => string | number) | undefined, index: number): string | number {
|
||||
if (!rowKey) return index;
|
||||
if (typeof rowKey === 'function') return rowKey(record);
|
||||
@ -87,34 +87,31 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
|
||||
const {
|
||||
fetchFn,
|
||||
columns,
|
||||
query = {} as Omit<Q, 'pageNum' | 'pageSize'>,
|
||||
searchFields,
|
||||
searchBar,
|
||||
toolbar,
|
||||
initialQuery = {} as Omit<Q, 'pageNum' | 'pageSize'>,
|
||||
rowKey,
|
||||
minWidth,
|
||||
pageSize: initialPageSize = DEFAULT_PAGE_SIZE,
|
||||
emptyText = '暂无数据',
|
||||
loadingText = '加载中...',
|
||||
autoRefresh = true,
|
||||
deps = [],
|
||||
searchGap = 'mb-4',
|
||||
} = props;
|
||||
|
||||
const [data, setData] = useState<Page<T> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true); // 首次加载显示 loading
|
||||
const [pageNum, setPageNum] = useState(DEFAULT_PAGE_NUM);
|
||||
const [pageSize] = useState(initialPageSize);
|
||||
const [searchValues, setSearchValues] = useState<Record<string, any>>(initialQuery);
|
||||
|
||||
// 是否首次加载
|
||||
const isFirstLoad = useRef(true);
|
||||
// 是否需要显示 loading
|
||||
const shouldShowLoading = useRef(true);
|
||||
// 上一次的 query(用于检测变化)
|
||||
const prevQueryRef = useRef(query);
|
||||
|
||||
// 加载数据
|
||||
const loadData = useCallback(async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true);
|
||||
// 加载数据(不再控制 loading 状态,只有首次加载显示)
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const result = await fetchFn({
|
||||
...query,
|
||||
...searchValues,
|
||||
pageNum,
|
||||
pageSize,
|
||||
} as Q);
|
||||
@ -122,47 +119,47 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
|
||||
} catch (error) {
|
||||
console.error('PaginatedTable 加载数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (isFirstLoad.current) {
|
||||
setLoading(false);
|
||||
isFirstLoad.current = false;
|
||||
}
|
||||
}
|
||||
}, [fetchFn, query, pageNum, pageSize]);
|
||||
}, [fetchFn, searchValues, pageNum, pageSize]);
|
||||
|
||||
// 监听分页变化
|
||||
useEffect(() => {
|
||||
loadData(shouldShowLoading.current || isFirstLoad.current);
|
||||
isFirstLoad.current = false;
|
||||
shouldShowLoading.current = false;
|
||||
}, [pageNum, pageSize, ...deps]);
|
||||
loadData();
|
||||
}, [pageNum, pageSize]);
|
||||
|
||||
// 监听 query 变化(自动刷新)
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
|
||||
const queryChanged = JSON.stringify(query) !== JSON.stringify(prevQueryRef.current);
|
||||
prevQueryRef.current = query;
|
||||
|
||||
if (queryChanged && !isFirstLoad.current) {
|
||||
shouldShowLoading.current = true;
|
||||
// 搜索
|
||||
const handleSearch = useCallback(() => {
|
||||
if (pageNum === DEFAULT_PAGE_NUM) {
|
||||
loadData();
|
||||
} else {
|
||||
setPageNum(DEFAULT_PAGE_NUM);
|
||||
}
|
||||
}, [query, autoRefresh]);
|
||||
}, [pageNum, loadData]);
|
||||
|
||||
// 重置
|
||||
const handleReset = useCallback(() => {
|
||||
setSearchValues(initialQuery);
|
||||
setPageNum(DEFAULT_PAGE_NUM);
|
||||
}, [initialQuery]);
|
||||
|
||||
// 分页切换
|
||||
const handlePageChange = useCallback((newPageNum: number) => {
|
||||
shouldShowLoading.current = false;
|
||||
setPageNum(newPageNum);
|
||||
}, []);
|
||||
|
||||
// 暴露方法给外部
|
||||
// 暴露方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh: async (showLoading = false) => {
|
||||
shouldShowLoading.current = showLoading;
|
||||
await loadData(showLoading);
|
||||
},
|
||||
reset: () => {
|
||||
shouldShowLoading.current = true;
|
||||
setPageNum(DEFAULT_PAGE_NUM);
|
||||
refresh: async () => {
|
||||
await loadData();
|
||||
},
|
||||
reset: handleReset,
|
||||
search: handleSearch,
|
||||
getData: () => data,
|
||||
getQuery: () => searchValues,
|
||||
updateRecord: (id, updates) => {
|
||||
setData(prev => {
|
||||
if (!prev) return prev;
|
||||
@ -174,9 +171,49 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
|
||||
};
|
||||
});
|
||||
},
|
||||
}), [data, loadData, rowKey]);
|
||||
}), [data, loadData, handleReset, handleSearch, searchValues, rowKey]);
|
||||
|
||||
// 渲染单元格内容
|
||||
// 渲染搜索字段
|
||||
const renderSearchField = (field: SearchFieldDef) => {
|
||||
const value = searchValues[field.key];
|
||||
|
||||
if (field.type === 'input') {
|
||||
return (
|
||||
<div key={field.key} className={field.width || 'flex-1 max-w-md'}>
|
||||
<Input
|
||||
placeholder={field.placeholder}
|
||||
value={value || ''}
|
||||
onChange={(e) => setSearchValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||
onKeyDown={(e) => field.enterSearch !== false && e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<Select
|
||||
key={field.key}
|
||||
value={value?.toString() || 'all'}
|
||||
onValueChange={(v) => setSearchValues(prev => ({ ...prev, [field.key]: v !== 'all' ? v : undefined }))}
|
||||
>
|
||||
<SelectTrigger className={field.width || 'w-[180px]'}>
|
||||
<SelectValue placeholder={field.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{field.placeholder || '全部'}</SelectItem>
|
||||
{field.options?.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value.toString()}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 渲染单元格
|
||||
const renderCell = (column: ColumnDef<T>, record: T, index: number) => {
|
||||
if (column.render) {
|
||||
const value = column.dataIndex ? getNestedValue(record, column.dataIndex) : undefined;
|
||||
@ -190,9 +227,29 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
|
||||
|
||||
const pageCount = data?.totalPages || 0;
|
||||
const hasData = data && data.content && data.content.length > 0;
|
||||
const showSearchBar = searchFields && searchFields.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{/* 搜索栏 */}
|
||||
{(showSearchBar || searchBar || toolbar) && (
|
||||
<div className={`flex flex-wrap items-center gap-4 ${searchGap}`}>
|
||||
{searchBar || (
|
||||
<>
|
||||
{searchFields?.map(renderSearchField)}
|
||||
<Button type="button" onClick={handleSearch}>
|
||||
<Search className="h-4 w-4 mr-2" />查询
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />重置
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{toolbar && <div className="ml-auto flex items-center gap-2">{toolbar}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="rounded-md border">
|
||||
<Table minWidth={minWidth}>
|
||||
<TableHeader>
|
||||
@ -205,7 +262,7 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && isFirstLoad.current ? (
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
@ -218,12 +275,7 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
|
||||
data.content.map((record, index) => (
|
||||
<TableRow key={getRowKey(record, rowKey, index)}>
|
||||
{columns.map(col => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
width={col.width}
|
||||
sticky={col.sticky}
|
||||
className={col.className}
|
||||
>
|
||||
<TableCell key={col.key} width={col.width} sticky={col.sticky} className={col.className}>
|
||||
{renderCell(col, record, index)}
|
||||
</TableCell>
|
||||
))}
|
||||
@ -240,6 +292,7 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{pageCount > 0 && (
|
||||
<DataTablePagination
|
||||
pageIndex={pageNum}
|
||||
@ -252,7 +305,6 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
|
||||
);
|
||||
}
|
||||
|
||||
// 使用 forwardRef 包装,支持泛型
|
||||
export const PaginatedTable = React.forwardRef(PaginatedTableInner) as <
|
||||
T extends Record<string, any>,
|
||||
Q extends BaseQuery = BaseQuery
|
||||
|
||||
@ -2,18 +2,15 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Plus, Search, Edit, Trash2, Play, Pause,
|
||||
Plus, Edit, Trash2, Play, Pause,
|
||||
Clock, Activity, CheckCircle2, XCircle, FolderKanban, PlayCircle, FileText, BarChart3, List, Ban
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { PaginatedTable, type ColumnDef, type PaginatedTableRef } from '@/components/ui/paginated-table';
|
||||
import { PaginatedTable, type ColumnDef, type PaginatedTableRef, type SearchFieldDef } from '@/components/ui/paginated-table';
|
||||
import { getScheduleJobs, getJobCategoryList, pauseJob, resumeJob, triggerJob, disableJob, deleteScheduleJob, enableJob } from './service';
|
||||
import type { ScheduleJobResponse, ScheduleJobQuery, JobCategoryResponse, JobStatus } from './types';
|
||||
import { DEFAULT_PAGE_SIZE } from '@/utils/page';
|
||||
import CategoryManageDialog from './components/CategoryManageDialog';
|
||||
import JobLogDialog from './components/JobLogDialog';
|
||||
import JobEditDialog from './components/JobEditDialog';
|
||||
@ -31,13 +28,6 @@ const ScheduleJobList: React.FC = () => {
|
||||
// 分类数据
|
||||
const [categories, setCategories] = useState<JobCategoryResponse[]>([]);
|
||||
|
||||
// 查询条件(不含分页)
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
jobName: '',
|
||||
categoryId: undefined as number | undefined,
|
||||
status: undefined as JobStatus | undefined,
|
||||
});
|
||||
|
||||
// 弹窗状态
|
||||
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
|
||||
const [logDialogOpen, setLogDialogOpen] = useState(false);
|
||||
@ -61,6 +51,13 @@ const ScheduleJobList: React.FC = () => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// 搜索字段定义
|
||||
const searchFields: SearchFieldDef[] = useMemo(() => [
|
||||
{ key: 'jobName', type: 'input', placeholder: '搜索任务名称...', enterSearch: true },
|
||||
{ key: 'categoryId', type: 'select', placeholder: '全部分类', width: 'w-[200px]', options: categories.map(c => ({ label: c.name, value: c.id })) },
|
||||
{ key: 'status', type: 'select', placeholder: '全部状态', width: 'w-[150px]', options: [{ label: '启用', value: 'ENABLED' }, { label: '禁用', value: 'DISABLED' }, { label: '暂停', value: 'PAUSED' }] },
|
||||
], [categories]);
|
||||
|
||||
// 状态徽章
|
||||
const getStatusBadge = (status: JobStatus) => {
|
||||
const statusMap: Record<JobStatus, { variant: 'default' | 'secondary' | 'destructive' | 'outline'; text: string; icon: React.ElementType }> = {
|
||||
@ -186,17 +183,6 @@ const ScheduleJobList: React.FC = () => {
|
||||
return { total, enabledCount, pausedCount };
|
||||
}, [tableRef.current?.getData()]);
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
tableRef.current?.reset();
|
||||
};
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
setSearchParams({ jobName: '', categoryId: undefined, status: undefined });
|
||||
tableRef.current?.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
@ -254,40 +240,13 @@ const ScheduleJobList: React.FC = () => {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||
<div className="flex-1 max-w-md">
|
||||
<Input placeholder="搜索任务名称..." value={searchParams.jobName} onChange={(e) => setSearchParams({ ...searchParams, jobName: e.target.value })} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} />
|
||||
</div>
|
||||
<Select value={searchParams.categoryId?.toString() || 'all'} onValueChange={(value) => setSearchParams({ ...searchParams, categoryId: value !== 'all' ? Number(value) : undefined })}>
|
||||
<SelectTrigger className="w-[200px]"><SelectValue placeholder="全部分类" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部分类</SelectItem>
|
||||
{categories.map((cat) => (<SelectItem key={cat.id} value={cat.id.toString()}>{cat.name}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={searchParams.status || 'all'} onValueChange={(value) => setSearchParams({ ...searchParams, status: value !== 'all' ? value as JobStatus : undefined })}>
|
||||
<SelectTrigger className="w-[150px]"><SelectValue placeholder="全部状态" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="ENABLED">启用</SelectItem>
|
||||
<SelectItem value="DISABLED">禁用</SelectItem>
|
||||
<SelectItem value="PAUSED">暂停</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" onClick={handleSearch}><Search className="h-4 w-4 mr-2" />查询</Button>
|
||||
<Button type="button" variant="outline" onClick={handleReset}>重置</Button>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<PaginatedTable<ScheduleJobResponse, ScheduleJobQuery>
|
||||
ref={tableRef}
|
||||
fetchFn={getScheduleJobs}
|
||||
columns={columns}
|
||||
query={searchParams}
|
||||
searchFields={searchFields}
|
||||
rowKey="id"
|
||||
minWidth="1600px"
|
||||
pageSize={DEFAULT_PAGE_SIZE}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user