mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
420 lines
16 KiB
TypeScript
420 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import type {
|
||
AnnouncementAudience,
|
||
AnnouncementDisplayType,
|
||
} from "@prisma/client";
|
||
import { toast } from "sonner";
|
||
import {
|
||
createAnnouncement,
|
||
updateAnnouncement,
|
||
} from "@/actions/admin/announcements";
|
||
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
|
||
import { BooleanToggle } from "@/components/ui/boolean-toggle";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogBody,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { getErrorMessage } from "@/lib/errors";
|
||
|
||
interface AnnouncementOptionUser {
|
||
id: string;
|
||
email: string;
|
||
}
|
||
|
||
interface AnnouncementFormData {
|
||
id: string;
|
||
title: string;
|
||
body: string;
|
||
audience: AnnouncementAudience;
|
||
displayType: AnnouncementDisplayType;
|
||
targetUserId: string | null;
|
||
dismissible: boolean;
|
||
sendNotification: boolean;
|
||
startAt: Date | string | null;
|
||
endAt: Date | string | null;
|
||
}
|
||
|
||
const audienceLabels: Record<AnnouncementAudience, string> = {
|
||
PUBLIC: "公开",
|
||
USERS: "全部用户",
|
||
ADMINS: "全部管理员",
|
||
SPECIFIC_USER: "指定用户",
|
||
};
|
||
|
||
const displayTypeLabels: Record<AnnouncementDisplayType, string> = {
|
||
INLINE: "普通公告",
|
||
BIG: "大公告",
|
||
POPUP: "弹窗公告",
|
||
};
|
||
|
||
function getAudienceLabel(value: unknown) {
|
||
return audienceLabels[value as AnnouncementAudience] ?? "选择范围";
|
||
}
|
||
|
||
function getDisplayTypeLabel(value: unknown) {
|
||
return displayTypeLabels[value as AnnouncementDisplayType] ?? "选择展示方式";
|
||
}
|
||
|
||
function getTargetUserLabel(users: AnnouncementOptionUser[], value: unknown) {
|
||
if (!value) return "不指定";
|
||
return users.find((user) => user.id === value)?.email ?? "选择用户";
|
||
}
|
||
|
||
function toDateTimeLocalValue(value: Date | string | null) {
|
||
if (!value) {
|
||
return "";
|
||
}
|
||
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return "";
|
||
}
|
||
|
||
const localTime = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||
return localTime.toISOString().slice(0, 16);
|
||
}
|
||
|
||
export function AnnouncementForm({
|
||
users,
|
||
announcement,
|
||
triggerLabel,
|
||
triggerVariant = "outline",
|
||
}: {
|
||
users: AnnouncementOptionUser[];
|
||
announcement: AnnouncementFormData;
|
||
triggerLabel?: string;
|
||
triggerVariant?: "default" | "outline" | "ghost";
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const [audience, setAudience] = useState<AnnouncementAudience>(announcement.audience);
|
||
|
||
async function handleSubmit(formData: FormData) {
|
||
try {
|
||
await updateAnnouncement(announcement.id, formData);
|
||
toast.success("公告已更新");
|
||
setOpen(false);
|
||
} catch (error) {
|
||
toast.error(getErrorMessage(error, "更新公告失败"));
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog
|
||
open={open}
|
||
onOpenChange={(nextOpen) => {
|
||
if (nextOpen) {
|
||
setAudience(announcement.audience);
|
||
}
|
||
setOpen(nextOpen);
|
||
}}
|
||
>
|
||
<DialogTrigger render={<Button variant={triggerVariant} size="sm" />}>
|
||
{triggerLabel ?? "编辑"}
|
||
</DialogTrigger>
|
||
<DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden p-0 sm:max-w-[30rem]">
|
||
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
|
||
<DialogTitle>编辑公告</DialogTitle>
|
||
</DialogHeader>
|
||
<DialogBody className="flex-1 px-4 py-3">
|
||
<form action={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs [&_[data-slot=select-trigger]]:!h-8 [&_[data-slot=select-trigger]]:!min-h-8 [&_[data-slot=select-trigger]]:!px-2.5 [&_[data-slot=select-trigger]]:!text-xs [&_textarea]:!text-xs">
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor={`title-${announcement.id}`}>标题</Label>
|
||
<Input id={`title-${announcement.id}`} name="title" defaultValue={announcement.title} required />
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor={`audience-${announcement.id}`}>目标范围</Label>
|
||
<Select
|
||
name="audience"
|
||
value={audience}
|
||
onValueChange={(value) => setAudience((value ?? "USERS") as AnnouncementAudience)}
|
||
>
|
||
<SelectTrigger id={`audience-${announcement.id}`} className="w-full">
|
||
<SelectValue>{(value) => getAudienceLabel(value)}</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent align="start">
|
||
<SelectItem value="PUBLIC">公开(登录/注册页可见)</SelectItem>
|
||
<SelectItem value="USERS">全部用户</SelectItem>
|
||
<SelectItem value="ADMINS">全部管理员</SelectItem>
|
||
<SelectItem value="SPECIFIC_USER">指定用户</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor={`displayType-${announcement.id}`}>展示方式</Label>
|
||
<Select
|
||
name="displayType"
|
||
defaultValue={announcement.displayType}
|
||
>
|
||
<SelectTrigger id={`displayType-${announcement.id}`} className="w-full">
|
||
<SelectValue>{(value) => getDisplayTypeLabel(value)}</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent align="start">
|
||
<SelectItem value="INLINE">普通公告</SelectItem>
|
||
<SelectItem value="BIG">大公告</SelectItem>
|
||
<SelectItem value="POPUP">弹窗公告</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor={`dismissible-${announcement.id}`}>允许用户关闭</Label>
|
||
<BooleanToggle
|
||
id={`dismissible-${announcement.id}`}
|
||
name="dismissible"
|
||
defaultValue={announcement.dismissible}
|
||
trueLabel="允许"
|
||
falseLabel="不允许"
|
||
ariaLabel="允许用户关闭"
|
||
size="compact"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor={`targetUserId-${announcement.id}`}>指定用户(可选)</Label>
|
||
<Select
|
||
name="targetUserId"
|
||
defaultValue={announcement.targetUserId ?? ""}
|
||
disabled={audience !== "SPECIFIC_USER"}
|
||
>
|
||
<SelectTrigger id={`targetUserId-${announcement.id}`} className="w-full">
|
||
<SelectValue>{(value) => getTargetUserLabel(users, value)}</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent align="start">
|
||
<SelectItem value="">不指定</SelectItem>
|
||
{users.map((user) => (
|
||
<SelectItem key={user.id} value={user.id}>
|
||
{user.email}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor={`body-${announcement.id}`}>内容</Label>
|
||
<Textarea
|
||
id={`body-${announcement.id}`}
|
||
name="body"
|
||
rows={5}
|
||
defaultValue={announcement.body}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor={`startAt-${announcement.id}`}>开始时间(可选)</Label>
|
||
<Input
|
||
id={`startAt-${announcement.id}`}
|
||
name="startAt"
|
||
type="datetime-local"
|
||
defaultValue={toDateTimeLocalValue(announcement.startAt)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor={`endAt-${announcement.id}`}>结束时间(可选)</Label>
|
||
<Input
|
||
id={`endAt-${announcement.id}`}
|
||
name="endAt"
|
||
type="datetime-local"
|
||
defaultValue={toDateTimeLocalValue(announcement.endAt)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor={`sendNotification-${announcement.id}`}>同步发送站内通知</Label>
|
||
<BooleanToggle
|
||
id={`sendNotification-${announcement.id}`}
|
||
name="sendNotification"
|
||
defaultValue={announcement.sendNotification}
|
||
trueLabel="发送"
|
||
falseLabel="不发送"
|
||
ariaLabel="同步发送站内通知"
|
||
size="compact"
|
||
/>
|
||
</div>
|
||
|
||
<PendingSubmitButton className="h-8 w-full" pendingLabel="保存中...">
|
||
保存修改
|
||
</PendingSubmitButton>
|
||
</form>
|
||
</DialogBody>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
export function CreateAnnouncementButton({
|
||
users,
|
||
}: {
|
||
users: AnnouncementOptionUser[];
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const [audience, setAudience] = useState<AnnouncementAudience>("USERS");
|
||
|
||
async function handleSubmit(formData: FormData) {
|
||
try {
|
||
await createAnnouncement(formData);
|
||
toast.success("公告已发布");
|
||
setOpen(false);
|
||
setAudience("USERS");
|
||
} catch (error) {
|
||
toast.error(getErrorMessage(error, "发布公告失败"));
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog
|
||
open={open}
|
||
onOpenChange={(nextOpen) => {
|
||
if (nextOpen) {
|
||
setAudience("USERS");
|
||
}
|
||
setOpen(nextOpen);
|
||
}}
|
||
>
|
||
<DialogTrigger render={<Button />}>发布公告</DialogTrigger>
|
||
<DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden p-0 sm:max-w-[30rem]">
|
||
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
|
||
<DialogTitle>发布公告</DialogTitle>
|
||
</DialogHeader>
|
||
<DialogBody className="flex-1 px-4 py-3">
|
||
<form action={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs [&_[data-slot=select-trigger]]:!h-8 [&_[data-slot=select-trigger]]:!min-h-8 [&_[data-slot=select-trigger]]:!px-2.5 [&_[data-slot=select-trigger]]:!text-xs [&_textarea]:!text-xs">
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="create-announcement-title">标题</Label>
|
||
<Input id="create-announcement-title" name="title" required />
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="create-announcement-audience">目标范围</Label>
|
||
<Select
|
||
name="audience"
|
||
value={audience}
|
||
onValueChange={(value) => setAudience((value ?? "USERS") as AnnouncementAudience)}
|
||
>
|
||
<SelectTrigger id="create-announcement-audience" className="w-full">
|
||
<SelectValue>{(value) => getAudienceLabel(value)}</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent align="start">
|
||
<SelectItem value="PUBLIC">公开(登录/注册页可见)</SelectItem>
|
||
<SelectItem value="USERS">全部用户</SelectItem>
|
||
<SelectItem value="ADMINS">全部管理员</SelectItem>
|
||
<SelectItem value="SPECIFIC_USER">指定用户</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="create-announcement-displayType">展示方式</Label>
|
||
<Select
|
||
name="displayType"
|
||
defaultValue="INLINE"
|
||
>
|
||
<SelectTrigger id="create-announcement-displayType" className="w-full">
|
||
<SelectValue>{(value) => getDisplayTypeLabel(value)}</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent align="start">
|
||
<SelectItem value="INLINE">普通公告</SelectItem>
|
||
<SelectItem value="BIG">大公告</SelectItem>
|
||
<SelectItem value="POPUP">弹窗公告</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="create-announcement-dismissible">允许用户关闭</Label>
|
||
<BooleanToggle
|
||
id="create-announcement-dismissible"
|
||
name="dismissible"
|
||
defaultValue
|
||
trueLabel="允许"
|
||
falseLabel="不允许"
|
||
ariaLabel="允许用户关闭"
|
||
size="compact"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="create-announcement-targetUserId">指定用户(可选)</Label>
|
||
<Select
|
||
name="targetUserId"
|
||
defaultValue=""
|
||
disabled={audience !== "SPECIFIC_USER"}
|
||
>
|
||
<SelectTrigger id="create-announcement-targetUserId" className="w-full">
|
||
<SelectValue>{(value) => getTargetUserLabel(users, value)}</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent align="start">
|
||
<SelectItem value="">不指定</SelectItem>
|
||
{users.map((user) => (
|
||
<SelectItem key={user.id} value={user.id}>
|
||
{user.email}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="create-announcement-body">内容</Label>
|
||
<Textarea id="create-announcement-body" name="body" rows={5} required />
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="create-announcement-startAt">开始时间(可选)</Label>
|
||
<Input id="create-announcement-startAt" name="startAt" type="datetime-local" />
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="create-announcement-endAt">结束时间(可选)</Label>
|
||
<Input id="create-announcement-endAt" name="endAt" type="datetime-local" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="create-announcement-sendNotification">同步发送站内通知</Label>
|
||
<BooleanToggle
|
||
id="create-announcement-sendNotification"
|
||
name="sendNotification"
|
||
defaultValue
|
||
trueLabel="发送"
|
||
falseLabel="不发送"
|
||
ariaLabel="同步发送站内通知"
|
||
size="compact"
|
||
/>
|
||
</div>
|
||
|
||
<PendingSubmitButton className="h-8 w-full" pendingLabel="发布中...">
|
||
发布
|
||
</PendingSubmitButton>
|
||
</form>
|
||
</DialogBody>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|