feat: build collaboration workspaces

This commit is contained in:
2026-07-03 03:02:36 -07:00
parent bf3dd256ab
commit 4ba2a11b88
53 changed files with 9557 additions and 29 deletions

View 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>
);
}

View File

@@ -0,0 +1,180 @@
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { getDatabase } from "@/modules/core/server/db";
import { accounts, noticeReadReceipts, notices } from "@/modules/core/server/schema";
import type { Notice, NoticeCenterData, NoticeInput } from "@/modules/notices/types";
export type NoticeMutationFailure = {
success: false;
reason: string;
status: number;
};
type ReadCountRow = {
noticeId: string;
count: number;
};
export function isNoticeMutationFailure(value: unknown): value is NoticeMutationFailure {
return (
typeof value === "object" &&
value !== null &&
"success" in value &&
(value as { success?: unknown }).success === false
);
}
function iso(value: Date): string {
return value.toISOString();
}
function optionalIso(value: Date | null): string | undefined {
return value ? iso(value) : undefined;
}
function toNotice(
row: typeof notices.$inferSelect,
accountNameById: Map<string, string>,
readCountByNoticeId: Map<string, number>,
readNoticeIds: Set<string>,
): Notice {
const createdByAccountId = row.createdByAccountId ?? undefined;
const updatedByAccountId = row.updatedByAccountId ?? undefined;
return {
id: row.id,
organizationId: row.organizationId,
title: row.title,
content: row.content,
audience: row.audience,
status: row.status,
publishedAt: optionalIso(row.publishedAt),
createdByAccountId,
createdByName: createdByAccountId ? accountNameById.get(createdByAccountId) : undefined,
updatedByAccountId,
updatedByName: updatedByAccountId ? accountNameById.get(updatedByAccountId) : undefined,
readCount: readCountByNoticeId.get(row.id) ?? 0,
hasRead: readNoticeIds.has(row.id),
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
export async function listNoticeCenterData(organizationId: string, accountId: string): Promise<NoticeCenterData> {
const database = getDatabase();
const noticeRows = await database.select().from(notices).where(eq(notices.organizationId, organizationId)).orderBy(desc(notices.updatedAt));
const noticeIds = noticeRows.map((notice) => notice.id);
const accountIds = Array.from(
new Set(
noticeRows
.flatMap((notice) => [notice.createdByAccountId, notice.updatedByAccountId])
.filter((item): item is string => typeof item === "string"),
),
);
const [accountRows, readCountRows, readRows] = await Promise.all([
accountIds.length > 0 ? database.select({ id: accounts.id, name: accounts.name }).from(accounts).where(inArray(accounts.id, accountIds)) : [],
noticeIds.length > 0
? database
.select({ noticeId: noticeReadReceipts.noticeId, count: sql<number>`count(*)::int` })
.from(noticeReadReceipts)
.where(and(eq(noticeReadReceipts.organizationId, organizationId), inArray(noticeReadReceipts.noticeId, noticeIds)))
.groupBy(noticeReadReceipts.noticeId)
: ([] as ReadCountRow[]),
noticeIds.length > 0
? database
.select({ noticeId: noticeReadReceipts.noticeId })
.from(noticeReadReceipts)
.where(
and(
eq(noticeReadReceipts.organizationId, organizationId),
eq(noticeReadReceipts.accountId, accountId),
inArray(noticeReadReceipts.noticeId, noticeIds),
),
)
: [],
]);
const readCountByNoticeId = new Map(readCountRows.map((row) => [row.noticeId, Number(row.count)]));
const readNoticeIds = new Set(readRows.map((row) => row.noticeId));
const noticeData = noticeRows.map((notice) =>
toNotice(
notice,
new Map(accountRows.map((account) => [account.id, account.name])),
readCountByNoticeId,
readNoticeIds,
),
);
return {
metrics: {
published: noticeRows.filter((notice) => notice.status === "published").length,
drafts: noticeRows.filter((notice) => notice.status === "draft").length,
unread: noticeRows.filter((notice) => notice.status === "published" && !readNoticeIds.has(notice.id)).length,
retracted: noticeRows.filter((notice) => notice.status === "retracted").length,
},
notices: noticeData,
};
}
export async function createNotice(input: NoticeInput & { accountId: string; organizationId: string }): Promise<Notice | NoticeMutationFailure> {
const database = getDatabase();
const now = new Date();
const rows = await database
.insert(notices)
.values({
organizationId: input.organizationId,
title: input.title,
content: input.content,
audience: input.audience,
status: input.status,
publishedAt: input.status === "published" ? now : undefined,
createdByAccountId: input.accountId,
updatedByAccountId: input.accountId,
})
.returning();
const notice = rows[0];
return notice ? toNotice(notice, new Map(), new Map(), new Set()) : { success: false, reason: "公告创建失败", status: 500 };
}
export async function updateNotice(input: NoticeInput & { accountId: string; id: string; organizationId: string }): Promise<Notice | NoticeMutationFailure> {
const database = getDatabase();
const now = new Date();
const rows = await database
.update(notices)
.set({
title: input.title,
content: input.content,
audience: input.audience,
status: input.status,
publishedAt: input.status === "published" ? now : null,
updatedByAccountId: input.accountId,
updatedAt: now,
})
.where(and(eq(notices.id, input.id), eq(notices.organizationId, input.organizationId)))
.returning();
const notice = rows[0];
return notice ? toNotice(notice, new Map(), new Map(), new Set()) : { success: false, reason: "公告不存在", status: 404 };
}
export async function deleteNotice(input: { id: string; organizationId: string }): Promise<Notice | NoticeMutationFailure> {
const database = getDatabase();
const rows = await database.delete(notices).where(and(eq(notices.id, input.id), eq(notices.organizationId, input.organizationId))).returning();
const notice = rows[0];
return notice ? toNotice(notice, new Map(), new Map(), new Set()) : { success: false, reason: "公告不存在", status: 404 };
}
export async function markNoticeRead(input: { accountId: string; id: string; organizationId: string }): Promise<Notice | NoticeMutationFailure> {
const database = getDatabase();
const rows = await database.select().from(notices).where(and(eq(notices.id, input.id), eq(notices.organizationId, input.organizationId)));
const notice = rows[0];
if (!notice) {
return { success: false, reason: "公告不存在", status: 404 };
}
await database
.insert(noticeReadReceipts)
.values({ organizationId: input.organizationId, noticeId: input.id, accountId: input.accountId })
.onConflictDoUpdate({
target: [noticeReadReceipts.noticeId, noticeReadReceipts.accountId],
set: { readAt: new Date() },
});
return toNotice(notice, new Map(), new Map([[notice.id, 1]]), new Set([notice.id]));
}

95
modules/notices/types.ts Normal file
View File

@@ -0,0 +1,95 @@
export const NOTICE_STATUS_VALUES = ["draft", "published", "retracted"] as const;
export type NoticeStatus = (typeof NOTICE_STATUS_VALUES)[number];
export const NOTICE_STATUS_LABELS: Record<NoticeStatus, string> = {
draft: "草稿",
published: "已发布",
retracted: "已撤回",
};
export type Notice = {
id: string;
organizationId: string;
title: string;
content: string;
audience: string;
status: NoticeStatus;
publishedAt?: string;
createdByAccountId?: string;
createdByName?: string;
updatedByAccountId?: string;
updatedByName?: string;
readCount: number;
hasRead: boolean;
createdAt: string;
updatedAt: string;
};
export type NoticeCenterData = {
metrics: {
published: number;
drafts: number;
unread: number;
retracted: number;
};
notices: Notice[];
};
export type NoticeInput = {
title: string;
content: string;
audience: string;
status: NoticeStatus;
};
type ValidationSuccess<T> = {
success: true;
data: T;
};
type ValidationFailure = {
success: false;
reason: string;
};
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readString(source: Record<string, unknown>, key: string): string {
const value = source[key];
return typeof value === "string" ? value.trim() : "";
}
function readEnum<T extends string>(value: unknown, values: readonly T[]): T | null {
return typeof value === "string" ? values.find((item) => item === value) ?? null : null;
}
export function validateNoticeInput(value: unknown): ValidationResult<NoticeInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const title = readString(value, "title");
const content = readString(value, "content");
if (!title || !content) {
return { success: false, reason: "公告标题和内容不能为空" };
}
const status = readEnum(value.status, NOTICE_STATUS_VALUES);
if (!status) {
return { success: false, reason: "公告状态无效" };
}
return {
success: true,
data: {
title,
content,
audience: readString(value, "audience") || "全体员工",
status,
},
};
}