重构消息通知弹窗

This commit is contained in:
dengqichen 2025-11-28 13:17:07 +08:00
parent 9275f5d4ae
commit 44ea1353b2
2 changed files with 137 additions and 126 deletions

View File

@ -1,29 +1,36 @@
import * as React from 'react'; import * as React from 'react';
import { useState, useEffect, useRef, useCallback, useImperativeHandle } 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 { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './table';
import { DataTablePagination } from './pagination'; 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 type { Page } from '@/types/base';
import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_NUM } from '@/utils/page'; import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_NUM } from '@/utils/page';
// 列定义 // 列定义
export interface ColumnDef<T> { export interface ColumnDef<T> {
/** 列标识 */
key: string; key: string;
/** 列标题 */
title: React.ReactNode; title: React.ReactNode;
/** 列宽度 */
width?: string; width?: string;
/** 是否固定列 */
sticky?: boolean; sticky?: boolean;
/** 自定义渲染 */
render?: (value: any, record: T, index: number) => React.ReactNode; render?: (value: any, record: T, index: number) => React.ReactNode;
/** 数据字段路径,支持嵌套如 'user.name' */
dataIndex?: string; dataIndex?: string;
/** 单元格类名 */
className?: 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 { export interface BaseQuery {
pageNum?: number; pageNum?: number;
@ -33,47 +40,40 @@ export interface BaseQuery {
// 组件 Props // 组件 Props
export interface PaginatedTableProps<T, Q extends BaseQuery = BaseQuery> { export interface PaginatedTableProps<T, Q extends BaseQuery = BaseQuery> {
/** 数据获取函数 */
fetchFn: (query: Q) => Promise<Page<T>>; fetchFn: (query: Q) => Promise<Page<T>>;
/** 列定义 */
columns: ColumnDef<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); rowKey?: keyof T | ((record: T) => string | number);
/** 表格最小宽度 */
minWidth?: string; minWidth?: string;
/** 初始页大小 */
pageSize?: number; pageSize?: number;
/** 空数据提示 */
emptyText?: React.ReactNode; emptyText?: React.ReactNode;
/** 加载中提示 */
loadingText?: React.ReactNode; loadingText?: React.ReactNode;
/** 是否在 query 变化时自动刷新 */ /** 搜索栏和表格之间的间距 */
autoRefresh?: boolean; searchGap?: string;
/** 额外的依赖项,变化时触发刷新 */
deps?: any[];
} }
// 暴露给外部的方法 // 暴露给外部的方法
export interface PaginatedTableRef<T> { export interface PaginatedTableRef<T> {
/** 刷新数据 */
refresh: (showLoading?: boolean) => Promise<void>; refresh: (showLoading?: boolean) => Promise<void>;
/** 重置到第一页并刷新 */
reset: () => void; reset: () => void;
/** 获取当前数据 */ search: () => void;
getData: () => Page<T> | null; getData: () => Page<T> | null;
/** 局部更新某条记录 */ getQuery: () => Record<string, any>;
updateRecord: (id: string | number, updates: Partial<T>) => void; updateRecord: (id: string | number, updates: Partial<T>) => void;
} }
// 获取嵌套属性值
function getNestedValue(obj: any, path?: string): any { function getNestedValue(obj: any, path?: string): any {
if (!path) return undefined; if (!path) return undefined;
return path.split('.').reduce((acc, part) => acc?.[part], obj); 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 { function getRowKey<T>(record: T, rowKey: keyof T | ((record: T) => string | number) | undefined, index: number): string | number {
if (!rowKey) return index; if (!rowKey) return index;
if (typeof rowKey === 'function') return rowKey(record); if (typeof rowKey === 'function') return rowKey(record);
@ -87,34 +87,31 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
const { const {
fetchFn, fetchFn,
columns, columns,
query = {} as Omit<Q, 'pageNum' | 'pageSize'>, searchFields,
searchBar,
toolbar,
initialQuery = {} as Omit<Q, 'pageNum' | 'pageSize'>,
rowKey, rowKey,
minWidth, minWidth,
pageSize: initialPageSize = DEFAULT_PAGE_SIZE, pageSize: initialPageSize = DEFAULT_PAGE_SIZE,
emptyText = '暂无数据', emptyText = '暂无数据',
loadingText = '加载中...', loadingText = '加载中...',
autoRefresh = true, searchGap = 'mb-4',
deps = [],
} = props; } = props;
const [data, setData] = useState<Page<T> | null>(null); 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 [pageNum, setPageNum] = useState(DEFAULT_PAGE_NUM);
const [pageSize] = useState(initialPageSize); const [pageSize] = useState(initialPageSize);
const [searchValues, setSearchValues] = useState<Record<string, any>>(initialQuery);
// 是否首次加载
const isFirstLoad = useRef(true); const isFirstLoad = useRef(true);
// 是否需要显示 loading
const shouldShowLoading = useRef(true);
// 上一次的 query用于检测变化
const prevQueryRef = useRef(query);
// 加载数据 // 加载数据(不再控制 loading 状态,只有首次加载显示)
const loadData = useCallback(async (showLoading = true) => { const loadData = useCallback(async () => {
if (showLoading) setLoading(true);
try { try {
const result = await fetchFn({ const result = await fetchFn({
...query, ...searchValues,
pageNum, pageNum,
pageSize, pageSize,
} as Q); } as Q);
@ -122,47 +119,47 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
} catch (error) { } catch (error) {
console.error('PaginatedTable 加载数据失败:', error); console.error('PaginatedTable 加载数据失败:', error);
} finally { } finally {
setLoading(false); if (isFirstLoad.current) {
setLoading(false);
isFirstLoad.current = false;
}
} }
}, [fetchFn, query, pageNum, pageSize]); }, [fetchFn, searchValues, pageNum, pageSize]);
// 监听分页变化 // 监听分页变化
useEffect(() => { useEffect(() => {
loadData(shouldShowLoading.current || isFirstLoad.current); loadData();
isFirstLoad.current = false; }, [pageNum, pageSize]);
shouldShowLoading.current = false;
}, [pageNum, pageSize, ...deps]);
// 监听 query 变化(自动刷新) // 搜索
useEffect(() => { const handleSearch = useCallback(() => {
if (!autoRefresh) return; if (pageNum === DEFAULT_PAGE_NUM) {
loadData();
const queryChanged = JSON.stringify(query) !== JSON.stringify(prevQueryRef.current); } else {
prevQueryRef.current = query;
if (queryChanged && !isFirstLoad.current) {
shouldShowLoading.current = true;
setPageNum(DEFAULT_PAGE_NUM); setPageNum(DEFAULT_PAGE_NUM);
} }
}, [query, autoRefresh]); }, [pageNum, loadData]);
// 重置
const handleReset = useCallback(() => {
setSearchValues(initialQuery);
setPageNum(DEFAULT_PAGE_NUM);
}, [initialQuery]);
// 分页切换 // 分页切换
const handlePageChange = useCallback((newPageNum: number) => { const handlePageChange = useCallback((newPageNum: number) => {
shouldShowLoading.current = false;
setPageNum(newPageNum); setPageNum(newPageNum);
}, []); }, []);
// 暴露方法给外部 // 暴露方法
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
refresh: async (showLoading = false) => { refresh: async () => {
shouldShowLoading.current = showLoading; await loadData();
await loadData(showLoading);
},
reset: () => {
shouldShowLoading.current = true;
setPageNum(DEFAULT_PAGE_NUM);
}, },
reset: handleReset,
search: handleSearch,
getData: () => data, getData: () => data,
getQuery: () => searchValues,
updateRecord: (id, updates) => { updateRecord: (id, updates) => {
setData(prev => { setData(prev => {
if (!prev) return 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) => { const renderCell = (column: ColumnDef<T>, record: T, index: number) => {
if (column.render) { if (column.render) {
const value = column.dataIndex ? getNestedValue(record, column.dataIndex) : undefined; 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 pageCount = data?.totalPages || 0;
const hasData = data && data.content && data.content.length > 0; const hasData = data && data.content && data.content.length > 0;
const showSearchBar = searchFields && searchFields.length > 0;
return ( return (
<div className="space-y-0"> <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"> <div className="rounded-md border">
<Table minWidth={minWidth}> <Table minWidth={minWidth}>
<TableHeader> <TableHeader>
@ -205,7 +262,7 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading && isFirstLoad.current ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center"> <TableCell colSpan={columns.length} className="h-24 text-center">
<div className="flex items-center justify-center gap-2"> <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) => ( data.content.map((record, index) => (
<TableRow key={getRowKey(record, rowKey, index)}> <TableRow key={getRowKey(record, rowKey, index)}>
{columns.map(col => ( {columns.map(col => (
<TableCell <TableCell key={col.key} width={col.width} sticky={col.sticky} className={col.className}>
key={col.key}
width={col.width}
sticky={col.sticky}
className={col.className}
>
{renderCell(col, record, index)} {renderCell(col, record, index)}
</TableCell> </TableCell>
))} ))}
@ -240,6 +292,7 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
</Table> </Table>
</div> </div>
{/* 分页 */}
{pageCount > 0 && ( {pageCount > 0 && (
<DataTablePagination <DataTablePagination
pageIndex={pageNum} pageIndex={pageNum}
@ -252,7 +305,6 @@ function PaginatedTableInner<T extends Record<string, any>, Q extends BaseQuery
); );
} }
// 使用 forwardRef 包装,支持泛型
export const PaginatedTable = React.forwardRef(PaginatedTableInner) as < export const PaginatedTable = React.forwardRef(PaginatedTableInner) as <
T extends Record<string, any>, T extends Record<string, any>,
Q extends BaseQuery = BaseQuery Q extends BaseQuery = BaseQuery

View File

@ -2,18 +2,15 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
Plus, Search, Edit, Trash2, Play, Pause, Plus, Edit, Trash2, Play, Pause,
Clock, Activity, CheckCircle2, XCircle, FolderKanban, PlayCircle, FileText, BarChart3, List, Ban Clock, Activity, CheckCircle2, XCircle, FolderKanban, PlayCircle, FileText, BarChart3, List, Ban
} from 'lucide-react'; } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast'; 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 { getScheduleJobs, getJobCategoryList, pauseJob, resumeJob, triggerJob, disableJob, deleteScheduleJob, enableJob } from './service';
import type { ScheduleJobResponse, ScheduleJobQuery, JobCategoryResponse, JobStatus } from './types'; import type { ScheduleJobResponse, ScheduleJobQuery, JobCategoryResponse, JobStatus } from './types';
import { DEFAULT_PAGE_SIZE } from '@/utils/page';
import CategoryManageDialog from './components/CategoryManageDialog'; import CategoryManageDialog from './components/CategoryManageDialog';
import JobLogDialog from './components/JobLogDialog'; import JobLogDialog from './components/JobLogDialog';
import JobEditDialog from './components/JobEditDialog'; import JobEditDialog from './components/JobEditDialog';
@ -31,13 +28,6 @@ const ScheduleJobList: React.FC = () => {
// 分类数据 // 分类数据
const [categories, setCategories] = useState<JobCategoryResponse[]>([]); 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 [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
const [logDialogOpen, setLogDialogOpen] = useState(false); const [logDialogOpen, setLogDialogOpen] = useState(false);
@ -61,6 +51,13 @@ const ScheduleJobList: React.FC = () => {
loadCategories(); 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 getStatusBadge = (status: JobStatus) => {
const statusMap: Record<JobStatus, { variant: 'default' | 'secondary' | 'destructive' | 'outline'; text: string; icon: React.ElementType }> = { 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 }; return { total, enabledCount, pausedCount };
}, [tableRef.current?.getData()]); }, [tableRef.current?.getData()]);
// 搜索
const handleSearch = () => {
tableRef.current?.reset();
};
// 重置
const handleReset = () => {
setSearchParams({ jobName: '', categoryId: undefined, status: undefined });
tableRef.current?.reset();
};
return ( return (
<div className="p-6"> <div className="p-6">
<div className="mb-6"> <div className="mb-6">
@ -254,40 +240,13 @@ const ScheduleJobList: React.FC = () => {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <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> <PaginatedTable<ScheduleJobResponse, ScheduleJobQuery>
ref={tableRef} ref={tableRef}
fetchFn={getScheduleJobs} fetchFn={getScheduleJobs}
columns={columns} columns={columns}
query={searchParams} searchFields={searchFields}
rowKey="id" rowKey="id"
minWidth="1600px" minWidth="1600px"
pageSize={DEFAULT_PAGE_SIZE}
/> />
</CardContent> </CardContent>
</Card> </Card>