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() {
|
export function Header() {
|
||||||
|
|
||||||
const handleSearch = (value: string) => {
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFilterChange = () => {
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-gradient-to-r from-primary to-primary-400 p-6">
|
<header className="bg-gradient-to-r from-primary to-primary-400 p-6">
|
||||||
<h1 className="text-3xl font-bold text-white">公开信件列表</h1>
|
<h1 className="text-3xl font-bold text-white">公开信件列表</h1>
|
||||||
|
@ -24,15 +14,7 @@ export function Header() {
|
||||||
<span>高效解决实际问题</span>
|
<span>高效解决实际问题</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SearchFilters
|
|
||||||
className="mt-4"
|
|
||||||
searchTerm=""
|
|
||||||
filterCategory={null}
|
|
||||||
filterStatus={null}
|
|
||||||
onSearchChange={handleSearch}
|
|
||||||
onCategoryChange={handleFilterChange}
|
|
||||||
onStatusChange={handleFilterChange}
|
|
||||||
/>
|
|
||||||
</header>
|
</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 { Header } from "./Header";
|
||||||
import { LetterCard } from "./LetterCard";
|
|
||||||
import { Pagination } from "./Pagination";
|
|
||||||
import { SearchFilters } from "./SearchFilter";
|
|
||||||
import { useLetterFilters } from "./useLetterFilters";
|
|
||||||
|
|
||||||
export default function LetterListPage() {
|
export default function LetterListPage() {
|
||||||
const {
|
|
||||||
searchTerm,
|
|
||||||
setSearchTerm,
|
|
||||||
filterCategory,
|
|
||||||
setFilterCategory,
|
|
||||||
filterStatus,
|
|
||||||
setFilterStatus,
|
|
||||||
filteredLetters,
|
|
||||||
} = useLetterFilters(letters);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// 添加 flex flex-col 使其成为弹性布局容器
|
// 添加 flex flex-col 使其成为弹性布局容器
|
||||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 flex flex-col">
|
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 flex flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
{/* 添加 flex-grow 使内容区域自动填充剩余空间 */}
|
{/* 添加 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>
|
</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 { EyeOutlined, LikeOutlined, LikeFilled, UserOutlined, BankOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||||
import { Button, Typography, Space, Tooltip } from 'antd';
|
import { Button, Typography, Space, Tooltip } from 'antd';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { Letter } from './types';
|
|
||||||
import { getBadgeStyle } from './utils';
|
|
||||||
import { useState } from 'react';
|
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;
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
|
||||||
interface LetterCardProps {
|
interface LetterCardProps {
|
||||||
letter: Letter;
|
letter: PostDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LetterCard({ letter }: LetterCardProps) {
|
export function LetterCard({ letter }: LetterCardProps) {
|
||||||
|
@ -54,9 +54,7 @@ export function LetterCard({ letter }: LetterCardProps) {
|
||||||
{letter.title}
|
{letter.title}
|
||||||
</a>
|
</a>
|
||||||
</Title>
|
</Title>
|
||||||
{letter.priority && (
|
|
||||||
<Badge type="priority" value={letter.priority} className="ml-2" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Meta Info */}
|
{/* Meta Info */}
|
||||||
|
@ -64,17 +62,17 @@ export function LetterCard({ letter }: LetterCardProps) {
|
||||||
<Space size="middle">
|
<Space size="middle">
|
||||||
<Space>
|
<Space>
|
||||||
<UserOutlined className="text-secondary-400" />
|
<UserOutlined className="text-secondary-400" />
|
||||||
<Text strong>{letter.sender}</Text>
|
<Text strong>{letter.author.showname}</Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Text type="secondary">|</Text>
|
<Text type="secondary">|</Text>
|
||||||
<Space>
|
<Space>
|
||||||
<BankOutlined className="text-secondary-400" />
|
<BankOutlined className="text-secondary-400" />
|
||||||
<Text>{letter.unit}</Text>
|
<Text>{letter.author.department.name}</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
<CalendarOutlined className="text-secondary-400" />
|
<CalendarOutlined className="text-secondary-400" />
|
||||||
<Text type="secondary">{letter.date}</Text>
|
<Text type="secondary">{dayjs(letter.createdAt).format('YYYY-MM-DD')}</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -94,8 +92,8 @@ export function LetterCard({ letter }: LetterCardProps) {
|
||||||
{/* Badges & Interactions */}
|
{/* Badges & Interactions */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<Space size="small" wrap className="flex-1">
|
<Space size="small" wrap className="flex-1">
|
||||||
<Badge type="category" value={letter.category} />
|
<Badge type="category" value={'11'} />
|
||||||
<Badge type="status" value={letter.status} />
|
<Badge type="status" value={'22'} />
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<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) => {
|
const getStaff = (key: string) => {
|
||||||
return findQueryData<Staff>(queryClient, api.staff, key);
|
return findQueryData<Staff>(queryClient, api.staff, key);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...useEntity("staff", {
|
...useEntity("staff", {
|
||||||
create: {
|
create: {
|
||||||
|
|
Loading…
Reference in New Issue