211 lines
10 KiB
TypeScript
211 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { FormEvent, useMemo, useState } from "react";
|
|
import { Bell, CheckCircle2, FileText, Search, Undo2 } from "lucide-react";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Dialog } from "@/components/ui/dialog";
|
|
import { Input, Textarea } from "@/components/ui/input";
|
|
import { Select } from "@/components/ui/select";
|
|
import { Table } from "@/components/ui/table";
|
|
import type { ApiResult } from "@/modules/core/server/api";
|
|
import type { Notice, NoticeCenterData, NoticeInput, NoticeStatus } from "@/modules/notices/types";
|
|
import { NOTICE_STATUS_LABELS, NOTICE_STATUS_VALUES } from "@/modules/notices/types";
|
|
|
|
type NoticesWorkspaceClientProps = {
|
|
canManage: boolean;
|
|
initialData: NoticeCenterData;
|
|
};
|
|
|
|
type DialogState = { mode: "create"; value: NoticeInput } | { mode: "edit"; id: string; value: NoticeInput };
|
|
|
|
const emptyNotice: NoticeInput = {
|
|
title: "",
|
|
content: "",
|
|
audience: "全体员工",
|
|
status: "draft",
|
|
};
|
|
|
|
function formatDateTime(value: string | undefined): string {
|
|
return value ? new Date(value).toLocaleString("zh-CN") : "-";
|
|
}
|
|
|
|
function noticeToInput(notice: Notice): NoticeInput {
|
|
return {
|
|
title: notice.title,
|
|
content: notice.content,
|
|
audience: notice.audience,
|
|
status: notice.status,
|
|
};
|
|
}
|
|
|
|
function badgeVariant(status: NoticeStatus): "secondary" | "success" | "warning" {
|
|
if (status === "published") {
|
|
return "success";
|
|
}
|
|
return status === "draft" ? "warning" : "secondary";
|
|
}
|
|
|
|
export function NoticesWorkspaceClient({ canManage, initialData }: NoticesWorkspaceClientProps): React.ReactElement {
|
|
const [data, setData] = useState(initialData);
|
|
const [query, setQuery] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState<"all" | NoticeStatus>("all");
|
|
const [dialog, setDialog] = useState<DialogState | undefined>();
|
|
const [message, setMessage] = useState("");
|
|
const [isPending, setIsPending] = useState(false);
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
|
|
const filteredNotices = useMemo(
|
|
() =>
|
|
data.notices.filter((notice) => {
|
|
const matchesQuery = !normalizedQuery || [notice.title, notice.content, notice.audience].join(" ").toLowerCase().includes(normalizedQuery);
|
|
const matchesStatus = statusFilter === "all" || notice.status === statusFilter;
|
|
return matchesQuery && matchesStatus;
|
|
}),
|
|
[data.notices, normalizedQuery, statusFilter],
|
|
);
|
|
|
|
async function refreshData(): Promise<void> {
|
|
const response = await fetch("/api/notices");
|
|
const result = (await response.json()) as ApiResult<{ data: NoticeCenterData }>;
|
|
if (result.success) {
|
|
setData(result.data);
|
|
}
|
|
}
|
|
|
|
async function submitDialog(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
event.preventDefault();
|
|
if (!dialog || !canManage) {
|
|
setMessage("当前角色无权维护公告");
|
|
return;
|
|
}
|
|
|
|
setIsPending(true);
|
|
const response = await fetch(dialog.mode === "create" ? "/api/notices" : `/api/notices/${dialog.id}`, {
|
|
method: dialog.mode === "create" ? "POST" : "PATCH",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(dialog.value),
|
|
});
|
|
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
|
|
setIsPending(false);
|
|
setMessage(result.reason);
|
|
if (result.success) {
|
|
setDialog(undefined);
|
|
await refreshData();
|
|
}
|
|
}
|
|
|
|
async function remove(id: string): Promise<void> {
|
|
if (!canManage) {
|
|
setMessage("当前角色无权删除公告");
|
|
return;
|
|
}
|
|
setIsPending(true);
|
|
const response = await fetch(`/api/notices/${id}`, { method: "DELETE" });
|
|
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
|
|
setIsPending(false);
|
|
setMessage(result.reason);
|
|
if (result.success) {
|
|
await refreshData();
|
|
}
|
|
}
|
|
|
|
async function markRead(id: string): Promise<void> {
|
|
setIsPending(true);
|
|
const response = await fetch(`/api/notices/${id}`, { method: "POST" });
|
|
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
|
|
setIsPending(false);
|
|
setMessage(result.reason);
|
|
if (result.success) {
|
|
await refreshData();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
<MetricCard icon={Bell} label="已发布" value={data.metrics.published} />
|
|
<MetricCard icon={FileText} label="草稿" value={data.metrics.drafts} />
|
|
<MetricCard icon={CheckCircle2} label="未读" value={data.metrics.unread} />
|
|
<MetricCard icon={Undo2} label="已撤回" value={data.metrics.retracted} />
|
|
</section>
|
|
|
|
<section className="flex flex-col gap-3 border-b pb-4 xl:flex-row xl:items-center xl:justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-semibold tracking-normal">公告通知</h1>
|
|
<p className="mt-2 text-sm text-muted-foreground">发布机构公告并跟踪阅读状态。</p>
|
|
</div>
|
|
<div className="grid gap-2 sm:grid-cols-2 xl:flex xl:items-center">
|
|
<label className="relative block sm:col-span-2 xl:col-span-1">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input className="w-full pl-9 xl:w-72" onChange={(event) => setQuery(event.target.value)} placeholder="搜索公告或范围" value={query} />
|
|
</label>
|
|
<Select aria-label="公告状态" onValueChange={(value) => setStatusFilter(value as "all" | NoticeStatus)} options={[{ value: "all", label: "全部状态" }, ...NOTICE_STATUS_VALUES.map((value) => ({ value, label: NOTICE_STATUS_LABELS[value] }))]} value={statusFilter} />
|
|
<Button disabled={!canManage} onClick={() => setDialog({ mode: "create", value: emptyNotice })} type="button">新增公告</Button>
|
|
</div>
|
|
</section>
|
|
|
|
{message ? <p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">{message}</p> : null}
|
|
|
|
<Card>
|
|
<CardHeader><CardTitle>公告列表</CardTitle></CardHeader>
|
|
<CardContent className="overflow-x-auto">
|
|
<Table className="w-full min-w-[980px] text-sm">
|
|
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
|
<Table.Row>
|
|
<Table.Head className="px-4 py-3">公告</Table.Head>
|
|
<Table.Head className="px-4 py-3">范围</Table.Head>
|
|
<Table.Head className="px-4 py-3">状态</Table.Head>
|
|
<Table.Head className="px-4 py-3">阅读</Table.Head>
|
|
<Table.Head className="px-4 py-3">发布时间</Table.Head>
|
|
<Table.Head className="px-4 py-3 text-right">操作</Table.Head>
|
|
</Table.Row>
|
|
</Table.Header>
|
|
<Table.Body className="divide-y bg-card">
|
|
{filteredNotices.map((notice) => (
|
|
<Table.Row key={notice.id}>
|
|
<Table.Cell className="px-4 py-3"><p className="font-medium">{notice.title}</p><p className="line-clamp-2 text-xs text-muted-foreground">{notice.content}</p></Table.Cell>
|
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{notice.audience}</Table.Cell>
|
|
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(notice.status)}>{NOTICE_STATUS_LABELS[notice.status]}</Badge></Table.Cell>
|
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{notice.hasRead ? "已读" : "未读"} / {notice.readCount}</Table.Cell>
|
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(notice.publishedAt)}</Table.Cell>
|
|
<Table.Cell className="px-4 py-3"><div className="flex justify-end gap-2"><Button disabled={isPending || notice.hasRead} onClick={() => markRead(notice.id)} size="sm" type="button" variant="outline">已读</Button><Button disabled={!canManage || isPending} onClick={() => setDialog({ mode: "edit", id: notice.id, value: noticeToInput(notice) })} size="sm" type="button" variant="outline">编辑</Button><Button disabled={!canManage || isPending} onClick={() => remove(notice.id)} size="sm" type="button" variant="outline">删除</Button></div></Table.Cell>
|
|
</Table.Row>
|
|
))}
|
|
{filteredNotices.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>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog onClose={() => setDialog(undefined)} open={dialog !== undefined} title="公告通知" width="lg">
|
|
{dialog ? (
|
|
<form className="grid gap-3" onSubmit={submitDialog}>
|
|
<Input label="标题" onChange={(event) => setDialog({ ...dialog, value: { ...dialog.value, title: event.target.value } })} required value={dialog.value.title} />
|
|
<Input label="发布范围" onChange={(event) => setDialog({ ...dialog, value: { ...dialog.value, audience: event.target.value } })} value={dialog.value.audience} />
|
|
<Select aria-label="公告状态" onValueChange={(status) => setDialog({ ...dialog, value: { ...dialog.value, status: status as NoticeStatus } })} options={NOTICE_STATUS_VALUES.map((status) => ({ value: status, label: NOTICE_STATUS_LABELS[status] }))} value={dialog.value.status} />
|
|
<Textarea label="内容" onChange={(event) => setDialog({ ...dialog, value: { ...dialog.value, content: event.target.value } })} required value={dialog.value.content} />
|
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
<Button disabled={isPending} onClick={() => setDialog(undefined)} type="button" variant="outline">取消</Button>
|
|
<Button disabled={isPending || !canManage} type="submit">保存</Button>
|
|
</div>
|
|
</form>
|
|
) : null}
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetricCard({ icon: Icon, label, value }: { icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>; label: string; value: number }): React.ReactElement {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between p-4">
|
|
<div><p className="text-sm text-muted-foreground">{label}</p><CardTitle className="mt-2 text-3xl">{value}</CardTitle></div>
|
|
<Icon className="size-5 text-primary" aria-hidden={true} />
|
|
</CardHeader>
|
|
</Card>
|
|
);
|
|
}
|