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

236 lines
8.8 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";
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>
<label className="relative block">
<Filter className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<select
className="h-11 w-full rounded-md border bg-background px-9 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
onChange={(event) => updateStatus(event.target.value)}
value={status}
>
{statusOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[860px] text-sm">
<thead className="bg-secondary text-left text-xs text-muted-foreground">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y bg-card">
{visibleRecords.map((record) => (
<tr key={record.id}>
<td 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>
</td>
<td className="px-4 py-3 text-muted-foreground">{record.category}</td>
<td className="px-4 py-3">{record.owner}</td>
<td className="px-4 py-3">
<Badge variant={record.statusTone}>{record.status}</Badge>
</td>
<td className="px-4 py-3">
<Badge variant={record.priority === "高" ? "danger" : record.priority === "中" ? "warning" : "secondary"}>
{record.priority}
</Badge>
</td>
<td 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>
</td>
</tr>
))}
{visibleRecords.length === 0 ? (
<tr>
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</td>
</tr>
) : null}
</tbody>
</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>
);
}