polish: refine admin ui controls

This commit is contained in:
JetSprow
2026-05-01 00:58:46 +10:00
parent b8a7cab1af
commit 4dd2f9280f
30 changed files with 651 additions and 307 deletions

View File

@@ -15,6 +15,7 @@ import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
@@ -22,6 +23,13 @@ import {
} 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";
@@ -43,6 +51,32 @@ interface AnnouncementFormData {
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 "";
@@ -94,48 +128,55 @@ export function AnnouncementForm({
<DialogTrigger render={<Button variant={triggerVariant} size="sm" />}>
{triggerLabel ?? "编辑"}
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<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>
<form action={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<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-2">
<div className="space-y-1.5">
<Label htmlFor={`audience-${announcement.id}`}></Label>
<select
id={`audience-${announcement.id}`}
<Select
name="audience"
defaultValue={announcement.audience}
onChange={(event) => setAudience(event.target.value as AnnouncementAudience)}
className="h-10 w-full px-3 text-sm outline-none"
value={audience}
onValueChange={(value) => setAudience((value ?? "USERS") as AnnouncementAudience)}
>
<option value="PUBLIC">/</option>
<option value="USERS"></option>
<option value="ADMINS"></option>
<option value="SPECIFIC_USER"></option>
</select>
<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-4 md:grid-cols-2">
<div className="space-y-2">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`displayType-${announcement.id}`}></Label>
<select
id={`displayType-${announcement.id}`}
<Select
name="displayType"
defaultValue={announcement.displayType}
className="h-10 w-full px-3 text-sm outline-none"
>
<option value="INLINE"></option>
<option value="BIG"></option>
<option value="POPUP"></option>
</select>
<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-2">
<div className="space-y-1.5">
<Label htmlFor={`dismissible-${announcement.id}`}></Label>
<BooleanToggle
id={`dismissible-${announcement.id}`}
@@ -144,29 +185,33 @@ export function AnnouncementForm({
trueLabel="允许"
falseLabel="不允许"
ariaLabel="允许用户关闭"
size="compact"
/>
</div>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<Label htmlFor={`targetUserId-${announcement.id}`}></Label>
<select
id={`targetUserId-${announcement.id}`}
<Select
name="targetUserId"
defaultValue={announcement.targetUserId ?? ""}
disabled={audience !== "SPECIFIC_USER"}
className="h-10 w-full px-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60"
>
<option value=""></option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.email}
</option>
))}
</select>
<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-2">
<div className="space-y-1.5">
<Label htmlFor={`body-${announcement.id}`}></Label>
<Textarea
id={`body-${announcement.id}`}
@@ -177,8 +222,8 @@ export function AnnouncementForm({
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<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}`}
@@ -187,7 +232,7 @@ export function AnnouncementForm({
defaultValue={toDateTimeLocalValue(announcement.startAt)}
/>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<Label htmlFor={`endAt-${announcement.id}`}></Label>
<Input
id={`endAt-${announcement.id}`}
@@ -198,7 +243,7 @@ export function AnnouncementForm({
</div>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<Label htmlFor={`sendNotification-${announcement.id}`}></Label>
<BooleanToggle
id={`sendNotification-${announcement.id}`}
@@ -207,13 +252,15 @@ export function AnnouncementForm({
trueLabel="发送"
falseLabel="不发送"
ariaLabel="同步发送站内通知"
size="compact"
/>
</div>
<PendingSubmitButton className="w-full" pendingLabel="保存中...">
<PendingSubmitButton className="h-8 w-full" pendingLabel="保存中...">
</PendingSubmitButton>
</form>
</DialogBody>
</DialogContent>
</Dialog>
);
@@ -239,50 +286,65 @@ export function CreateAnnouncementButton({
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
setAudience("USERS");
}
setOpen(nextOpen);
}}
>
<DialogTrigger render={<Button />}></DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<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>
<form action={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<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-2">
<div className="space-y-1.5">
<Label htmlFor="create-announcement-audience"></Label>
<select
id="create-announcement-audience"
<Select
name="audience"
defaultValue="USERS"
onChange={(event) => setAudience(event.target.value as AnnouncementAudience)}
className="h-10 w-full px-3 text-sm outline-none"
value={audience}
onValueChange={(value) => setAudience((value ?? "USERS") as AnnouncementAudience)}
>
<option value="PUBLIC">/</option>
<option value="USERS"></option>
<option value="ADMINS"></option>
<option value="SPECIFIC_USER"></option>
</select>
<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-4 md:grid-cols-2">
<div className="space-y-2">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="create-announcement-displayType"></Label>
<select
id="create-announcement-displayType"
<Select
name="displayType"
defaultValue="INLINE"
className="h-10 w-full px-3 text-sm outline-none"
>
<option value="INLINE"></option>
<option value="BIG"></option>
<option value="POPUP"></option>
</select>
<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-2">
<div className="space-y-1.5">
<Label htmlFor="create-announcement-dismissible"></Label>
<BooleanToggle
id="create-announcement-dismissible"
@@ -291,45 +353,49 @@ export function CreateAnnouncementButton({
trueLabel="允许"
falseLabel="不允许"
ariaLabel="允许用户关闭"
size="compact"
/>
</div>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<Label htmlFor="create-announcement-targetUserId"></Label>
<select
id="create-announcement-targetUserId"
<Select
name="targetUserId"
defaultValue=""
disabled={audience !== "SPECIFIC_USER"}
className="h-10 w-full px-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60"
>
<option value=""></option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.email}
</option>
))}
</select>
<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-2">
<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-4 md:grid-cols-2">
<div className="space-y-2">
<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-2">
<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-2">
<div className="space-y-1.5">
<Label htmlFor="create-announcement-sendNotification"></Label>
<BooleanToggle
id="create-announcement-sendNotification"
@@ -338,13 +404,15 @@ export function CreateAnnouncementButton({
trueLabel="发送"
falseLabel="不发送"
ariaLabel="同步发送站内通知"
size="compact"
/>
</div>
<PendingSubmitButton className="w-full" pendingLabel="发布中...">
<PendingSubmitButton className="h-8 w-full" pendingLabel="发布中...">
</PendingSubmitButton>
</form>
</DialogBody>
</DialogContent>
</Dialog>
);