feat: build collaboration workspaces
This commit is contained in:
210
modules/notices/components/NoticesWorkspaceClient.tsx
Normal file
210
modules/notices/components/NoticesWorkspaceClient.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user