Files
teatea-pension/modules/shared/components/CmsModuleClient.tsx

234 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import { Eye, Filter, Search } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
export type CmsRecordTone = "success" | "warning" | "danger" | "secondary";
export type CmsRecord = {
id: string;
title: string;
owner: string;
status: string;
statusTone: CmsRecordTone;
category: string;
updatedAt: string;
priority: "高" | "中" | "低";
summary: string;
};
type CmsModuleClientProps = {
records: CmsRecord[];
};
const PAGE_SIZE = 5;
const ALL_STATUS = "全部状态";
function matchesRecord(record: CmsRecord, query: string, status: string): boolean {
const normalizedQuery = query.trim().toLowerCase();
const matchesSearch =
normalizedQuery.length === 0 ||
[record.title, record.owner, record.category, record.summary]
.join(" ")
.toLowerCase()
.includes(normalizedQuery);
const matchesStatus = status === ALL_STATUS || record.status === status;
return matchesSearch && matchesStatus;
}
export function CmsModuleClient({ records }: CmsModuleClientProps): React.ReactElement {
const [query, setQuery] = useState("");
const [status, setStatus] = useState(ALL_STATUS);
const [page, setPage] = useState(1);
const [selectedRecord, setSelectedRecord] = useState<CmsRecord | null>(null);
const statusOptions = useMemo(() => {
const uniqueStatuses = new Set(records.map((record) => record.status));
return [ALL_STATUS, ...Array.from(uniqueStatuses)];
}, [records]);
const filteredRecords = useMemo(
() => records.filter((record) => matchesRecord(record, query, status)),
[query, records, status],
);
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / PAGE_SIZE));
const safePage = Math.min(page, pageCount);
const pageStart = (safePage - 1) * PAGE_SIZE;
const visibleRecords = filteredRecords.slice(pageStart, pageStart + PAGE_SIZE);
const highPriorityCount = filteredRecords.filter((record) => record.priority === "高").length;
function updateQuery(value: string): void {
setQuery(value);
setPage(1);
}
function updateStatus(value: string): void {
setStatus(value);
setPage(1);
}
return (
<section className="rounded-lg border bg-card">
<div className="flex flex-col gap-4 border-b p-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-base font-semibold"></h2>
<p className="mt-1 text-sm text-muted-foreground">
{filteredRecords.length} {highPriorityCount}
</p>
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(220px,1fr)_160px]">
<label className="relative block">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
onChange={(event) => updateQuery(event.target.value)}
placeholder="搜索名称、负责人、分类"
value={query}
/>
</label>
<div className="relative block">
<Filter className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Select
aria-label="按状态筛选"
className="w-full pl-9"
onValueChange={updateStatus}
options={statusOptions.map((option) => ({ value: option, label: option }))}
value={status}
/>
</div>
</div>
</div>
<div className="overflow-x-auto">
<Table className="w-full min-w-[860px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{visibleRecords.map((record) => (
<Table.Row key={record.id}>
<Table.Cell className="px-4 py-3">
<p className="font-medium">{record.title}</p>
<p className="mt-1 max-w-md truncate text-xs text-muted-foreground">{record.summary}</p>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{record.category}</Table.Cell>
<Table.Cell className="px-4 py-3">{record.owner}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={record.statusTone}>{record.status}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={record.priority === "高" ? "danger" : record.priority === "中" ? "warning" : "secondary"}>
{record.priority}
</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3">
<div className="flex justify-end">
<Button onClick={() => setSelectedRecord(record)} size="sm" type="button" variant="outline">
<Eye className="size-4" aria-hidden="true" />
</Button>
</div>
</Table.Cell>
</Table.Row>
))}
{visibleRecords.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
<div className="flex flex-col gap-3 border-t p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
{safePage} / {pageCount}
</p>
<div className="flex gap-2">
<Button
disabled={safePage === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
size="sm"
type="button"
variant="outline"
>
</Button>
<Button
disabled={safePage === pageCount}
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
size="sm"
type="button"
variant="outline"
>
</Button>
</div>
</div>
<Dialog
description={selectedRecord?.summary}
onClose={() => setSelectedRecord(null)}
open={selectedRecord !== null}
title={selectedRecord?.title ?? "记录详情"}
>
{selectedRecord ? (
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div className="rounded-md border p-3">
<dt className="text-muted-foreground"></dt>
<dd className="mt-1 font-medium">{selectedRecord.category}</dd>
</div>
<div className="rounded-md border p-3">
<dt className="text-muted-foreground"></dt>
<dd className="mt-1 font-medium">{selectedRecord.owner}</dd>
</div>
<div className="rounded-md border p-3">
<dt className="text-muted-foreground"></dt>
<dd className="mt-1">
<Badge variant={selectedRecord.statusTone}>{selectedRecord.status}</Badge>
</dd>
</div>
<div className="rounded-md border p-3">
<dt className="text-muted-foreground"></dt>
<dd className="mt-1 font-medium">{selectedRecord.updatedAt}</dd>
</div>
<div className="rounded-md border p-3 sm:col-span-2">
<dt className="text-muted-foreground"></dt>
<dd className="mt-2">
<Badge
variant={
selectedRecord.priority === "高"
? "danger"
: selectedRecord.priority === "中"
? "warning"
: "secondary"
}
>
{selectedRecord.priority}
</Badge>
</dd>
</div>
</dl>
) : null}
</Dialog>
</section>
);
}