01250052
This commit is contained in:
parent
8981f75f21
commit
9f6df6d948
|
@ -1,16 +1,6 @@
|
|||
import { SearchFilters } from "./SearchFilter";
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export function Header() {
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
|
||||
};
|
||||
|
||||
const handleFilterChange = () => {
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-gradient-to-r from-primary to-primary-400 p-6">
|
||||
<h1 className="text-3xl font-bold text-white">公开信件列表</h1>
|
||||
|
@ -24,15 +14,7 @@ export function Header() {
|
|||
<span>高效解决实际问题</span>
|
||||
</div>
|
||||
</div>
|
||||
<SearchFilters
|
||||
className="mt-4"
|
||||
searchTerm=""
|
||||
filterCategory={null}
|
||||
filterStatus={null}
|
||||
onSearchChange={handleSearch}
|
||||
onCategoryChange={handleFilterChange}
|
||||
onStatusChange={handleFilterChange}
|
||||
/>
|
||||
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,167 +0,0 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface PaginationProps {
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
currentPage?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
}
|
||||
export function Pagination({
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
currentPage = 1,
|
||||
onPageChange = () => { },
|
||||
}: PaginationProps) {
|
||||
const STYLE_CONFIG = {
|
||||
colors: {
|
||||
primary: 'bg-primary', // USAF Blue
|
||||
hover: 'hover:bg-[#00264d]', // Darker USAF Blue
|
||||
disabled: 'bg-disabled',
|
||||
text: {
|
||||
primary: 'text-primary',
|
||||
light: 'text-white',
|
||||
secondary: 'text-tertiary-400'
|
||||
}
|
||||
},
|
||||
components: {
|
||||
container: `
|
||||
flex items-center justify-between
|
||||
px-4 py-3 sm:px-6
|
||||
`,
|
||||
pagination: `
|
||||
inline-flex shadow-sm rounded-md
|
||||
divide-x divide-gray-200
|
||||
`,
|
||||
button: `
|
||||
relative inline-flex items-center justify-center
|
||||
min-w-[2.5rem] h-10
|
||||
text-sm font-medium
|
||||
transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2
|
||||
`
|
||||
}
|
||||
};
|
||||
// Memoized calculations
|
||||
const totalPages = useMemo(() => Math.ceil(totalItems / itemsPerPage), [totalItems, itemsPerPage]);
|
||||
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
// Navigation handlers
|
||||
const handlePrevious = () => currentPage > 1 && onPageChange(currentPage - 1);
|
||||
const handleNext = () => currentPage < totalPages && onPageChange(currentPage + 1);
|
||||
|
||||
// Generate page numbers with improved logic
|
||||
const getPageNumbers = () => {
|
||||
const maxVisiblePages = 5;
|
||||
if (totalPages <= maxVisiblePages) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
}
|
||||
if (currentPage <= 3) {
|
||||
return [1, 2, 3, '...', totalPages];
|
||||
}
|
||||
if (currentPage >= totalPages - 2) {
|
||||
return [1, '...', totalPages - 2, totalPages - 1, totalPages];
|
||||
}
|
||||
return [
|
||||
1,
|
||||
'...',
|
||||
currentPage - 1,
|
||||
currentPage,
|
||||
currentPage + 1,
|
||||
'...',
|
||||
totalPages
|
||||
];
|
||||
};
|
||||
|
||||
const renderPageButton = (pageNum: number | string, index: number) => {
|
||||
if (pageNum === '...') {
|
||||
return (
|
||||
<span
|
||||
key={`ellipsis-${index}`}
|
||||
className={`
|
||||
${STYLE_CONFIG.components.button}
|
||||
${STYLE_CONFIG.colors.text.secondary}
|
||||
bg-white
|
||||
`}
|
||||
>
|
||||
…
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const isCurrentPage = currentPage === pageNum;
|
||||
const buttonStyle = isCurrentPage
|
||||
? `
|
||||
${STYLE_CONFIG.components.button}
|
||||
${STYLE_CONFIG.colors.primary}
|
||||
${STYLE_CONFIG.colors.text.light}
|
||||
${STYLE_CONFIG.colors.hover}
|
||||
`
|
||||
: `
|
||||
${STYLE_CONFIG.components.button}
|
||||
bg-white
|
||||
${STYLE_CONFIG.colors.text.primary}
|
||||
hover:bg-gray-50
|
||||
`;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => onPageChange(pageNum as number)}
|
||||
className={buttonStyle}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className={STYLE_CONFIG.components.container}>
|
||||
<div className="flex-1 flex items-center justify-between">
|
||||
<div>
|
||||
<p className={STYLE_CONFIG.colors.text.secondary}>
|
||||
Showing <span className="font-semibold">{startItem}</span> to{' '}
|
||||
<span className="font-semibold">{endItem}</span> of{' '}
|
||||
<span className="font-semibold">{totalItems}</span> results
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="relative z-0 inline-flex">
|
||||
<div className={STYLE_CONFIG.components.pagination}>
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={currentPage === 1}
|
||||
className={`
|
||||
${STYLE_CONFIG.components.button}
|
||||
rounded-l-md
|
||||
${currentPage === 1 ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
bg-white
|
||||
${STYLE_CONFIG.colors.text.primary}
|
||||
hover:bg-gray-50
|
||||
`}
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{getPageNumbers().map(renderPageButton)}
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`
|
||||
${STYLE_CONFIG.components.button}
|
||||
rounded-r-md
|
||||
${currentPage === totalPages ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
bg-white
|
||||
${STYLE_CONFIG.colors.text.primary}
|
||||
hover:bg-gray-50
|
||||
`}
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,90 +0,0 @@
|
|||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { Form, Input, Select, Spin } from 'antd';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface SearchFiltersProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
filterCategory: string;
|
||||
onCategoryChange: (value: string) => void;
|
||||
filterStatus: string;
|
||||
onStatusChange: (value: string) => void;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const LoadingIndicator = () => (
|
||||
<Spin size="small" className="ml-2" />
|
||||
);
|
||||
|
||||
export function SearchFilters({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
filterCategory,
|
||||
onCategoryChange,
|
||||
filterStatus,
|
||||
onStatusChange,
|
||||
className,
|
||||
isLoading = false
|
||||
}: SearchFiltersProps) {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 统一处理表单初始值
|
||||
const initialValues = {
|
||||
search: searchTerm,
|
||||
category: filterCategory,
|
||||
status: filterStatus
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(initialValues);
|
||||
}, [searchTerm, filterCategory, filterStatus, form]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
className={className}
|
||||
initialValues={initialValues}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Form.Item name="search" noStyle>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索关键词、发件人或单位..."
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
allowClear
|
||||
suffix={isLoading ? <LoadingIndicator /> : null}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="category" noStyle>
|
||||
<Select
|
||||
className="w-full"
|
||||
onChange={onCategoryChange}
|
||||
options={[
|
||||
{ value: 'all', label: '所有分类' },
|
||||
{ value: 'complaint', label: '投诉' },
|
||||
{ value: 'suggestion', label: '建议' },
|
||||
{ value: 'request', label: '请求' },
|
||||
{ value: 'feedback', label: '反馈' }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="status" noStyle>
|
||||
<Select
|
||||
className="w-full"
|
||||
onChange={onStatusChange}
|
||||
options={[
|
||||
{ value: 'all', label: '所有状态' },
|
||||
{ value: 'pending', label: '待处理' },
|
||||
{ value: 'in-progress', label: '处理中' },
|
||||
{ value: 'resolved', label: '已解决' }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
|
@ -1,37 +1,14 @@
|
|||
|
||||
import { letters } from "./constants";
|
||||
import { Header } from "./Header";
|
||||
import { LetterCard } from "./LetterCard";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { SearchFilters } from "./SearchFilter";
|
||||
import { useLetterFilters } from "./useLetterFilters";
|
||||
|
||||
export default function LetterListPage() {
|
||||
const {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
filterCategory,
|
||||
setFilterCategory,
|
||||
filterStatus,
|
||||
setFilterStatus,
|
||||
filteredLetters,
|
||||
} = useLetterFilters(letters);
|
||||
|
||||
return (
|
||||
// 添加 flex flex-col 使其成为弹性布局容器
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 flex flex-col">
|
||||
<Header />
|
||||
{/* 添加 flex-grow 使内容区域自动填充剩余空间 */}
|
||||
<div className="flex-grow flex flex-col gap-2">
|
||||
{filteredLetters.map((letter) => (
|
||||
<LetterCard key={letter.id} letter={letter} />
|
||||
))}
|
||||
</div>
|
||||
{/* Pagination 会自然固定在底部 */}
|
||||
<Pagination
|
||||
totalItems={filteredLetters.length}
|
||||
itemsPerPage={2}
|
||||
/>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { Letter } from './types';
|
||||
|
||||
export function useLetterFilters(letters: Letter[]) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
const [filterCategory, setFilterCategory] = useState<string>('all');
|
||||
|
||||
const filteredLetters = useMemo(() => {
|
||||
return letters.filter(letter => {
|
||||
const matchesSearch = [letter.title, letter.sender, letter.unit]
|
||||
.some(field => field.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
const matchesCategory = filterCategory === 'all' || letter.category === filterCategory;
|
||||
const matchesStatus = filterStatus === 'all' || letter.status === filterStatus;
|
||||
|
||||
return matchesSearch && matchesCategory && matchesStatus;
|
||||
});
|
||||
}, [letters, searchTerm, filterCategory, filterStatus]);
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
filterStatus,
|
||||
setFilterStatus,
|
||||
filterCategory,
|
||||
setFilterCategory,
|
||||
filteredLetters,
|
||||
};
|
||||
}
|
|
@ -1,14 +1,14 @@
|
|||
import { EyeOutlined, LikeOutlined, LikeFilled, UserOutlined, BankOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { Button, Typography, Space, Tooltip } from 'antd';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Letter } from './types';
|
||||
import { getBadgeStyle } from './utils';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { getBadgeStyle } from '@web/src/app/main/letter/list/utils';
|
||||
import { PostDto } from '@nice/common';
|
||||
import dayjs from 'dayjs';
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
interface LetterCardProps {
|
||||
letter: Letter;
|
||||
letter: PostDto;
|
||||
}
|
||||
|
||||
export function LetterCard({ letter }: LetterCardProps) {
|
||||
|
@ -54,9 +54,7 @@ export function LetterCard({ letter }: LetterCardProps) {
|
|||
{letter.title}
|
||||
</a>
|
||||
</Title>
|
||||
{letter.priority && (
|
||||
<Badge type="priority" value={letter.priority} className="ml-2" />
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Meta Info */}
|
||||
|
@ -64,17 +62,17 @@ export function LetterCard({ letter }: LetterCardProps) {
|
|||
<Space size="middle">
|
||||
<Space>
|
||||
<UserOutlined className="text-secondary-400" />
|
||||
<Text strong>{letter.sender}</Text>
|
||||
<Text strong>{letter.author.showname}</Text>
|
||||
</Space>
|
||||
<Text type="secondary">|</Text>
|
||||
<Space>
|
||||
<BankOutlined className="text-secondary-400" />
|
||||
<Text>{letter.unit}</Text>
|
||||
<Text>{letter.author.department.name}</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
<Space>
|
||||
<CalendarOutlined className="text-secondary-400" />
|
||||
<Text type="secondary">{letter.date}</Text>
|
||||
<Text type="secondary">{dayjs(letter.createdAt).format('YYYY-MM-DD')}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
@ -94,8 +92,8 @@ export function LetterCard({ letter }: LetterCardProps) {
|
|||
{/* Badges & Interactions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Space size="small" wrap className="flex-1">
|
||||
<Badge type="category" value={letter.category} />
|
||||
<Badge type="status" value={letter.status} />
|
||||
<Badge type="category" value={'11'} />
|
||||
<Badge type="status" value={'22'} />
|
||||
</Space>
|
||||
|
||||
<div className="flex items-center gap-4">
|
|
@ -0,0 +1,18 @@
|
|||
import { api, RouterInputs } from "@nice/client";
|
||||
import { Prisma } from "packages/common/dist";
|
||||
import { LetterCard } from "../LetterCard";
|
||||
|
||||
export default function LetterList({ params }: { params: RouterInputs["post"]["findManyWithPagination"] }) {
|
||||
|
||||
const { data, isLoading } = api.post.findManyWithPagination.useQuery({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
where: {},
|
||||
select: {}
|
||||
})
|
||||
return <div className="flex-grow flex flex-col gap-2">
|
||||
{data?.items.map((letter: any) => (
|
||||
<LetterCard key={letter.id} letter={letter} />
|
||||
))}
|
||||
</div>
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import { api } from "@nice/client";
|
||||
|
||||
export default function LetterList(){
|
||||
|
||||
}
|
|
@ -9,7 +9,6 @@ export function useStaff() {
|
|||
const getStaff = (key: string) => {
|
||||
return findQueryData<Staff>(queryClient, api.staff, key);
|
||||
};
|
||||
|
||||
return {
|
||||
...useEntity("staff", {
|
||||
create: {
|
||||
|
|
Loading…
Reference in New Issue