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>
|
||||
);
|
||||
}
|
||||
180
modules/notices/server/operations.ts
Normal file
180
modules/notices/server/operations.ts
Normal 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
95
modules/notices/types.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user