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

View File

@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type DiscountType = "AMOUNT_OFF" | "PERCENT_OFF";
const discountTypeLabels: Record<DiscountType, string> = {
AMOUNT_OFF: "立减金额",
PERCENT_OFF: "折扣百分比",
};
function getDiscountTypeLabel(value: unknown) {
return discountTypeLabels[value as DiscountType] ?? "选择优惠类型";
}
export function DiscountTypeSelect({
defaultValue = "AMOUNT_OFF",
}: {
defaultValue?: DiscountType;
}) {
const [value, setValue] = useState<DiscountType>(defaultValue);
return (
<Select
name="discountType"
value={value}
onValueChange={(nextValue) => setValue((nextValue ?? "AMOUNT_OFF") as DiscountType)}
>
<SelectTrigger id="coupon-type" className="w-full">
<SelectValue>{(selectedValue) => getDiscountTypeLabel(selectedValue)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="AMOUNT_OFF"></SelectItem>
<SelectItem value="PERCENT_OFF"></SelectItem>
</SelectContent>
</Select>
);
}

View File

@@ -10,8 +10,15 @@ import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getCommerceData } from "./commerce-data";
import { CommerceToggleButton } from "./_components/commerce-actions";
import { DiscountTypeSelect } from "./_components/discount-type-select";
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
function formatCouponDiscount(type: string, value: unknown) {
const numericValue = Number(value);
if (type === "PERCENT_OFF") {
return `折扣 ${numericValue}%`;
}
return `立减 ¥${numericValue.toFixed(2)}`;
}
export const metadata: Metadata = {
title: "商业配置",
@@ -51,10 +58,7 @@ export default async function AdminCommercePage() {
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="coupon-type"></Label>
<select id="coupon-type" name="discountType" className={selectClassName} defaultValue="AMOUNT_OFF">
<option value="AMOUNT_OFF"></option>
<option value="PERCENT_OFF"></option>
</select>
<DiscountTypeSelect />
</div>
<div className="space-y-2">
<Label htmlFor="coupon-value"></Label>
@@ -123,7 +127,7 @@ export default async function AdminCommercePage() {
</div>
<div className="flex flex-wrap gap-2">
<StatusBadge tone="warning">
{coupon.discountType === "PERCENT_OFF" ? `${Number(coupon.discountValue)}%` : `¥${Number(coupon.discountValue).toFixed(2)}`}
{formatCouponDiscount(coupon.discountType, coupon.discountValue)}
</StatusBadge>
<StatusBadge>{coupon.thresholdAmount == null ? "无门槛" : `满 ¥${Number(coupon.thresholdAmount).toFixed(2)}`}</StatusBadge>
<StatusBadge>{coupon.isPublic ? "公开展示" : "仅发放"}</StatusBadge>

View File

@@ -153,7 +153,7 @@ export function NodeActions({
</ConfirmActionButton>
<Dialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-[30rem]">
<DialogHeader>
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-primary">
<KeyRound className="size-3.5" /> PROBE TOKEN
@@ -161,7 +161,7 @@ export function NodeActions({
<DialogTitle> Token {node.name}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground"> Token</div>
<div className="rounded-lg border border-border bg-muted/30 p-3">

View File

@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
@@ -72,8 +73,8 @@ export function NodeForm({
{isEdit ? <Pencil className="size-3.5" /> : <Plus className="size-4" />}
{triggerLabel || (isEdit ? "编辑" : "添加节点")}
</DialogTrigger>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden p-0 sm:max-w-[34rem]">
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<Server className="size-4" />
</div>
@@ -81,10 +82,11 @@ export function NodeForm({
<DialogDescription> 3x-ui </DialogDescription>
</DialogHeader>
<form action={isEdit ? handleEdit : handleCreate} className="space-y-5">
<div className="rounded-lg border border-border bg-muted/20 p-4">
<div className="mb-3 text-sm font-semibold"></div>
<div className="grid gap-4 sm:grid-cols-2">
<DialogBody className="flex-1 px-4 py-3">
<form action={isEdit ? handleEdit : handleCreate} 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">
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="mb-2 text-xs font-semibold"></div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={nameId}></Label>
<Input id={nameId} name="name" defaultValue={node?.name ?? ""} placeholder="HK-01" />
@@ -102,9 +104,9 @@ export function NodeForm({
</div>
</div>
<div className="rounded-lg border border-border bg-muted/20 p-4">
<div className="mb-3 text-sm font-semibold"></div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="mb-2 text-xs font-semibold"></div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={panelUsernameId}></Label>
<Input id={panelUsernameId} name="panelUsername" defaultValue={node?.panelUsername ?? ""} required />
@@ -123,15 +125,16 @@ export function NodeForm({
</div>
</div>
<DialogFooter className="-mx-6 -mb-6">
<Button type="button" variant="outline" size="lg" onClick={() => setOpen(false)}>
<DialogFooter className="-mx-4 -mb-3">
<Button type="button" variant="outline" className="h-8" onClick={() => setOpen(false)}>
</Button>
<PendingSubmitButton size="lg" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
<PendingSubmitButton className="h-8" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
{isEdit ? "保存并同步" : "创建并同步"}
</PendingSubmitButton>
</DialogFooter>
</form>
</DialogBody>
</DialogContent>
</Dialog>
);

View File

@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { InlineHelp } from "@/components/ui/inline-help";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
@@ -197,8 +198,8 @@ export function PaymentConfigItem({
<Pencil className="size-3.5" />
</DialogTrigger>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto bg-card sm:max-w-3xl">
<DialogHeader>
<DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden bg-card p-0 sm:max-w-[34rem]">
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<ShieldCheck className="size-4" />
</div>
@@ -206,13 +207,14 @@ export function PaymentConfigItem({
<DialogDescription></DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<DialogBody className="flex-1 px-4 py-3">
<form onSubmit={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">
<div className="grid gap-3 sm:grid-cols-2">
{fields.map((field) =>
field.type === "checkboxes" ? (
<div key={field.key} className="sm:col-span-2">
<Label>{field.label}</Label>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{field.options?.map((option) => {
const selected = checkboxValues[field.key]?.has(option.value) ?? false;
return (
@@ -222,7 +224,7 @@ export function PaymentConfigItem({
aria-pressed={selected}
onClick={() => toggleCheckbox(field.key, option.value)}
className={cn(
"flex min-h-10 items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20",
"flex min-h-8 items-center justify-between gap-2 rounded-lg border px-2.5 py-1.5 text-left text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20",
selected
? "border-primary/35 bg-primary/10 text-primary"
: "border-border bg-muted/20 text-muted-foreground hover:bg-muted/45 hover:text-foreground",
@@ -236,7 +238,7 @@ export function PaymentConfigItem({
</div>
</div>
) : (
<div key={field.key} className="space-y-2">
<div key={field.key} className="space-y-1.5">
<Label htmlFor={`${provider}-${field.key}`}>{field.label}</Label>
<Input
id={`${provider}-${field.key}`}
@@ -253,11 +255,11 @@ export function PaymentConfigItem({
)}
</div>
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="rounded-lg border border-border bg-muted/20 p-2.5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label className="whitespace-nowrap text-sm font-semibold"></Label>
<Label className="whitespace-nowrap text-xs font-semibold"></Label>
<InlineHelp align="start"></InlineHelp>
</div>
</div>
@@ -269,15 +271,16 @@ export function PaymentConfigItem({
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={saving}>
<DialogFooter className="-mx-4 -mb-3">
<Button type="button" variant="outline" className="h-8" onClick={() => setOpen(false)} disabled={saving}>
</Button>
<Button type="submit" disabled={saving}>
<Button type="submit" className="h-8" disabled={saving}>
{saving ? "保存中..." : "保存配置"}
</Button>
</DialogFooter>
</form>
</DialogBody>
</DialogContent>
</Dialog>
</section>

View File

@@ -51,10 +51,13 @@ export function PlanBasicsFields({
}: PlanBasicsFieldsProps) {
return (
<>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("type")}></Label>
{isEdit ? (
<div id={fieldId("type")} className="premium-input flex h-11 items-center px-3 text-sm font-medium">
<div
id={fieldId("type")}
className="premium-input flex h-8 min-h-8 items-center px-2.5 text-xs font-medium"
>
{type === "PROXY" ? "代理节点套餐" : "流媒体套餐"}
</div>
) : (
@@ -86,12 +89,12 @@ export function PlanBasicsFields({
)}
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor={fieldId("name")}></Label>
<Input id={fieldId("name")} name="name" defaultValue={plan?.name ?? ""} required />
</div>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("durationDays")}></Label>
<Input
id={fieldId("durationDays")}
@@ -101,7 +104,7 @@ export function PlanBasicsFields({
required
/>
</div>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("sortOrder")}></Label>
<Input
id={fieldId("sortOrder")}
@@ -113,7 +116,7 @@ export function PlanBasicsFields({
</div>
</div>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("description")}></Label>
<Textarea
id={fieldId("description")}
@@ -121,6 +124,7 @@ export function PlanBasicsFields({
rows={2}
defaultValue={plan?.description ?? ""}
placeholder="展示给用户"
className="min-h-16 px-2.5 py-2 text-xs leading-5"
/>
</div>
</>
@@ -135,8 +139,8 @@ export function PlanLimitsFields({
plan?: PlanFormValue;
}) {
return (
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={fieldId("totalLimit")}></Label>
<Input
id={fieldId("totalLimit")}
@@ -147,7 +151,7 @@ export function PlanLimitsFields({
placeholder="空=不限"
/>
</div>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("perUserLimit")}></Label>
<Input
id={fieldId("perUserLimit")}

View File

@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
@@ -31,8 +32,8 @@ export type { PlanFormValue, StreamingServiceOption } from "./plan-form-types";
function FormSection({ title, children }: { title: string; children: ReactNode }) {
return (
<fieldset className="space-y-4 rounded-lg border border-border bg-muted/20 p-4">
<legend className="px-1.5 text-sm font-semibold">{title}</legend>
<fieldset className="space-y-3 rounded-lg border border-border bg-muted/20 p-3">
<legend className="px-1.5 text-xs font-semibold">{title}</legend>
{children}
</fieldset>
);
@@ -171,13 +172,17 @@ export function PlanForm({
>
{triggerLabel ?? (isEdit ? "编辑" : "创建套餐")}
</DialogTrigger>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-6xl xl:max-w-7xl">
<DialogHeader>
<DialogContent className="flex max-h-[min(90dvh,42rem)] flex-col overflow-hidden p-0 sm:max-w-[56rem]">
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<form onSubmit={(event) => void handleSubmit(event)} className="grid gap-4 lg:grid-cols-2">
<DialogBody className="flex-1 px-4 py-3">
<form
onSubmit={(event) => void handleSubmit(event)}
className="grid gap-3 text-[12px] leading-5 lg:grid-cols-2 [&_[data-slot=button]]:text-xs [&_[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"
>
{/* Left column: basics + resource config */}
<div className="space-y-4">
<div className="space-y-3">
<FormSection title="基础信息">
<PlanBasicsFields
fieldId={fieldId}
@@ -224,7 +229,7 @@ export function PlanForm({
</div>
{/* Right column: pricing (proxy only) + sales policy + submit */}
<div className="space-y-4">
<div className="space-y-3">
{type === "PROXY" && (
<FormSection title="定价">
<ProxyPricingFields
@@ -253,11 +258,12 @@ export function PlanForm({
/>
</FormSection>
<Button type="submit" size="lg" className="w-full" disabled={submitting}>
<Button type="submit" className="h-8 w-full" disabled={submitting}>
{submitting ? "提交中..." : (isEdit ? "保存套餐" : "创建套餐")}
</Button>
</div>
</form>
</DialogBody>
</DialogContent>
</Dialog>
);

View File

@@ -44,14 +44,14 @@ function PolicyToggleRow({
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-3 rounded-lg bg-muted/20 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-2 rounded-md bg-muted/20 p-2 sm:min-h-10 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-fit shrink-0 items-center gap-1.5">
<p id={labelId} className="whitespace-nowrap text-sm font-medium">
<p id={labelId} className="whitespace-nowrap text-xs font-medium">
{label}
</p>
<InlineHelp align="start">{help}</InlineHelp>
</div>
<div className="w-full sm:w-40 sm:shrink-0">{children}</div>
<div className="w-full sm:w-32 sm:shrink-0">{children}</div>
</div>
);
}
@@ -71,7 +71,7 @@ export function PlanPolicySection({
}: PlanPolicySectionProps) {
return (
<>
<div className="form-panel grid gap-3 xl:grid-cols-2">
<div className="grid gap-2 rounded-lg border border-border bg-card/75 p-2 shadow-[var(--shadow-soft)] xl:grid-cols-2">
<PolicyToggleRow
labelId={fieldId("allowRenewal-label")}
label="开放续费"
@@ -83,6 +83,7 @@ export function PlanPolicySection({
trueLabel="开放"
falseLabel="关闭"
ariaLabel="开放续费"
size="compact"
/>
</PolicyToggleRow>
{type === "PROXY" && (
@@ -97,15 +98,16 @@ export function PlanPolicySection({
trueLabel="开放"
falseLabel="关闭"
ariaLabel="开放增流量"
size="compact"
/>
</PolicyToggleRow>
)}
</div>
{allowRenewal && (
<div className="space-y-3 rounded-xl border border-border bg-muted/20 p-4">
<div className="space-y-2 rounded-lg border border-border bg-muted/15 p-3">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("renewalPricingMode")}></Label>
<input type="hidden" name="renewalPricingMode" value={renewalPricingMode} />
<Select value={renewalPricingMode} onValueChange={(value) => setRenewalPricingMode(value as RenewalPricingMode)}>
@@ -120,7 +122,7 @@ export function PlanPolicySection({
</SelectContent>
</Select>
</div>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("renewalPrice")}>
{renewalPricingMode === "PER_DAY" ? "续费价格(¥/天)" : "续费价格(¥/周期)"}
</Label>
@@ -139,7 +141,7 @@ export function PlanPolicySection({
<div className="grid gap-3 sm:grid-cols-2">
{renewalPricingMode === "FIXED_DURATION" ? (
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("renewalDurationDays")}></Label>
<Input
id={fieldId("renewalDurationDays")}
@@ -153,7 +155,7 @@ export function PlanPolicySection({
</div>
) : (
<>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("renewalMinDays")}></Label>
<Input
id={fieldId("renewalMinDays")}
@@ -164,7 +166,7 @@ export function PlanPolicySection({
placeholder="1"
/>
</div>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("renewalMaxDays")}></Label>
<Input
id={fieldId("renewalMaxDays")}
@@ -182,9 +184,9 @@ export function PlanPolicySection({
)}
{type === "PROXY" && allowTrafficTopup && (
<div className="space-y-3 rounded-xl border border-border bg-muted/20 p-4">
<div className="space-y-2 rounded-lg border border-border bg-muted/15 p-3">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("topupPricingMode")}></Label>
<input type="hidden" name="topupPricingMode" value={topupPricingMode} />
<Select value={topupPricingMode} onValueChange={(value) => setTopupPricingMode(value as TopupPricingMode)}>
@@ -200,7 +202,7 @@ export function PlanPolicySection({
</Select>
</div>
{topupPricingMode === "PER_GB" ? (
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("topupPricePerGb")}>¥/GB</Label>
<Input
id={fieldId("topupPricePerGb")}
@@ -214,7 +216,7 @@ export function PlanPolicySection({
/>
</div>
) : (
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("topupFixedPrice")}>¥</Label>
<Input
id={fieldId("topupFixedPrice")}
@@ -231,7 +233,7 @@ export function PlanPolicySection({
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("minTopupGb")}>GB</Label>
<Input
id={fieldId("minTopupGb")}
@@ -242,7 +244,7 @@ export function PlanPolicySection({
placeholder="1"
/>
</div>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("maxTopupGb")}>GB</Label>
<Input
id={fieldId("maxTopupGb")}

View File

@@ -43,7 +43,7 @@ export function ProxyNodeFields({
}) {
return (
<>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("nodeId")}></Label>
<Select
value={nodeId}
@@ -71,7 +71,7 @@ export function ProxyNodeFields({
</Select>
</div>
<div>
<div className="space-y-1.5">
<Label id={fieldId("inboundIds-label")}></Label>
<input type="hidden" name="inboundIds" value={selectedInboundIds.join(",")} />
<div className="grid gap-2 sm:grid-cols-2" role="group" aria-labelledby={fieldId("inboundIds-label")}>
@@ -82,17 +82,17 @@ export function ProxyNodeFields({
key={inbound.id}
type="button"
className={cn(
"choice-card text-left px-3 py-2.5 text-sm",
"choice-card flex min-h-8 items-center justify-between gap-2 px-2.5 py-1.5 text-left text-xs leading-4",
selected
? "border-primary/30 bg-primary/10 text-primary"
: "hover:bg-muted/45",
)}
onClick={() => toggleInbound(inbound.id)}
>
<p className="font-medium">
<span className="shrink-0 font-medium leading-4">
{inbound.protocol} · {inbound.port}
</p>
<p className="text-xs text-muted-foreground">{inbound.tag}</p>
</span>
<span className="min-w-0 truncate text-[11px] leading-4 text-muted-foreground">{inbound.tag}</span>
</button>
);
})}
@@ -123,7 +123,7 @@ export function ProxyPricingFields({
return (
<>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("pricingMode")}></Label>
<Select value={pricingMode} onValueChange={(value) => setPricingMode(value as PlanPricingMode)}>
<SelectTrigger id={fieldId("pricingMode")}>
@@ -139,8 +139,8 @@ export function ProxyPricingFields({
</div>
{pricingMode === "TRAFFIC_SLIDER" ? (
<div className="grid gap-4 sm:grid-cols-3">
<div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor={fieldId("pricePerGb")}>¥/GB</Label>
<Input
id={fieldId("pricePerGb")}
@@ -151,7 +151,7 @@ export function ProxyPricingFields({
placeholder="0.5"
/>
</div>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("minTrafficGb")}> GB</Label>
<Input
id={fieldId("minTrafficGb")}
@@ -161,7 +161,7 @@ export function ProxyPricingFields({
placeholder="10"
/>
</div>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("maxTrafficGb")}> GB</Label>
<Input
id={fieldId("maxTrafficGb")}
@@ -173,8 +173,8 @@ export function ProxyPricingFields({
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={fieldId("fixedTrafficGb")}>GB</Label>
<Input
id={fieldId("fixedTrafficGb")}
@@ -185,7 +185,7 @@ export function ProxyPricingFields({
placeholder="200"
/>
</div>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("fixedPrice")}>¥</Label>
<Input
id={fieldId("fixedPrice")}
@@ -200,7 +200,7 @@ export function ProxyPricingFields({
</div>
)}
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("totalTrafficGb")}>GB</Label>
<Input
id={fieldId("totalTrafficGb")}

View File

@@ -34,7 +34,7 @@ export function StreamingConfigSection({
}: StreamingConfigSectionProps) {
return (
<>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("streamingServiceId")}></Label>
<Select
value={streamingServiceId}
@@ -72,7 +72,7 @@ export function StreamingConfigSection({
)}
</div>
<div>
<div className="space-y-1.5">
<Label htmlFor={fieldId("price")}>¥</Label>
<Input
id={fieldId("price")}

View File

@@ -9,7 +9,9 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -50,17 +52,18 @@ export function ServiceForm({
<DialogTrigger render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />}>
{triggerLabel ?? (isEdit ? "编辑" : "添加服务")}
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogContent className="flex max-h-[min(90dvh,34rem)] flex-col overflow-hidden p-0 sm:max-w-[30rem]">
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<DialogTitle>{isEdit ? "编辑流媒体服务" : "添加流媒体服务"}</DialogTitle>
<p className="text-sm leading-6 text-muted-foreground"></p>
<p className="text-xs leading-5 text-muted-foreground"></p>
</DialogHeader>
<form action={handleSubmit} className="form-panel space-y-5">
<div>
<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 [&_textarea]:!text-xs">
<div className="space-y-1.5">
<Label> ( Netflix)</Label>
<Input name="name" defaultValue={service?.name} required />
</div>
<div>
<div className="space-y-1.5">
<Label> ()</Label>
<Textarea
name="credentials"
@@ -71,20 +74,24 @@ export function ServiceForm({
? "重新输入凭据"
: "email: xxx&#10;password: xxx"
}
className="min-h-20 px-2.5 py-2 text-xs leading-5"
/>
</div>
<div>
<div className="space-y-1.5">
<Label></Label>
<Input name="maxSlots" type="number" defaultValue={service?.maxSlots ?? 5} required />
</div>
<div>
<div className="space-y-1.5">
<Label></Label>
<Input name="description" defaultValue={service?.description ?? ""} />
</div>
<PendingSubmitButton size="lg" className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
{isEdit ? "保存" : "创建"}
</PendingSubmitButton>
<DialogFooter className="-mx-4 -mb-3">
<PendingSubmitButton className="h-8 w-full sm:w-auto" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
{isEdit ? "保存" : "创建"}
</PendingSubmitButton>
</DialogFooter>
</form>
</DialogBody>
</DialogContent>
</Dialog>
);

View File

@@ -10,6 +10,13 @@ import { Button, buttonVariants } from "@/components/ui/button";
import { InlineHelp } from "@/components/ui/inline-help";
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 {
saveAppSettings,
@@ -82,7 +89,6 @@ interface CouponOption {
name: string;
}
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
const sectionClassName = "surface-card scroll-mt-24 space-y-4 rounded-xl p-4";
const sectionHeadingClassName = "flex items-center gap-2 text-sm font-semibold";
@@ -480,18 +486,21 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_12rem_auto] lg:items-end">
<div className="space-y-2">
<Label htmlFor="manualCleanupTarget"></Label>
<select
id="manualCleanupTarget"
<Select
value={cleanupTarget}
onChange={(event) => setCleanupTarget(event.target.value as LogCleanupTarget)}
className={selectClassName}
onValueChange={(value) => setCleanupTarget((value ?? "ALL") as LogCleanupTarget)}
>
{logCleanupTargetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<SelectTrigger id="manualCleanupTarget" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent align="start">
{logCleanupTargetOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="manualCleanupDays"></Label>
@@ -510,6 +519,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
description={`清理 ${manualCleanupDays || config.logRetentionDays || 30} 天前的${logCleanupTargetOptions.find((option) => option.value === cleanupTarget)?.label ?? "日志"},无法恢复。`}
confirmLabel="开始清理"
errorMessage="清理日志失败"
size="lg"
disabled={saving || hasPendingToggle || cleaningLogs}
onConfirm={handleCleanupExpiredLogs}
>
@@ -805,19 +815,22 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div>
<div className="space-y-2">
<Label htmlFor="inviteRewardCouponId"></Label>
<select
id="inviteRewardCouponId"
<Select
name="inviteRewardCouponId"
defaultValue={config.inviteRewardCouponId ?? ""}
className={selectClassName}
>
<option value=""></option>
{coupons.map((coupon) => (
<option key={coupon.id} value={coupon.id}>
{coupon.name} · {coupon.code}
</option>
))}
</select>
<SelectTrigger id="inviteRewardCouponId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent align="start">
<SelectItem value=""></SelectItem>
{coupons.map((coupon) => (
<SelectItem key={coupon.id} value={coupon.id}>
{coupon.name} · {coupon.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</section>

View File

@@ -1,44 +1,17 @@
import { updateSupportTicketMeta } from "@/actions/admin/support";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Label } from "@/components/ui/label";
import type { AdminSupportTicketDetail } from "../support-data";
import { SupportTicketMetaSelects } from "./support-ticket-meta-selects";
export function SupportTicketMetaForm({ ticket }: { ticket: AdminSupportTicketDetail }) {
return (
<form
action={updateSupportTicketMeta}
className="surface-card flex flex-wrap items-end gap-3 rounded-xl p-4"
className="surface-card grid gap-3 rounded-xl p-4 sm:grid-cols-[minmax(10rem,12rem)_minmax(10rem,12rem)_auto] sm:items-end"
>
<input type="hidden" name="ticketId" value={ticket.id} />
<div className="space-y-2">
<Label htmlFor="status"></Label>
<select
id="status"
name="status"
defaultValue={ticket.status}
className="h-11 px-3 text-sm outline-none"
>
<option value="OPEN"></option>
<option value="USER_REPLIED"></option>
<option value="ADMIN_REPLIED"></option>
<option value="CLOSED"></option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="priority"></Label>
<select
id="priority"
name="priority"
defaultValue={ticket.priority}
className="h-11 px-3 text-sm outline-none"
>
<option value="LOW"></option>
<option value="NORMAL"></option>
<option value="HIGH"></option>
<option value="URGENT"></option>
</select>
</div>
<PendingSubmitButton variant="outline" size="lg" pendingLabel="更新中..."></PendingSubmitButton>
<SupportTicketMetaSelects status={ticket.status} priority={ticket.priority} />
<PendingSubmitButton variant="outline" size="lg" className="w-full sm:w-auto" pendingLabel="更新中..."></PendingSubmitButton>
</form>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import type { SupportTicketPriority, SupportTicketStatus } from "@prisma/client";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
supportTicketPriorityLabels,
supportTicketStatusLabels,
} from "@/services/support-labels";
function getStatusLabel(value: unknown) {
return supportTicketStatusLabels[value as SupportTicketStatus] ?? "选择状态";
}
function getPriorityLabel(value: unknown) {
return supportTicketPriorityLabels[value as SupportTicketPriority] ?? "选择优先级";
}
export function SupportTicketMetaSelects({
status,
priority,
}: {
status: SupportTicketStatus;
priority: SupportTicketPriority;
}) {
const [selectedStatus, setSelectedStatus] = useState(status);
const [selectedPriority, setSelectedPriority] = useState(priority);
return (
<>
<div className="min-w-44 space-y-2">
<Label htmlFor="status"></Label>
<Select
name="status"
value={selectedStatus}
onValueChange={(value) => setSelectedStatus((value ?? "OPEN") as SupportTicketStatus)}
>
<SelectTrigger id="status" className="w-full">
<SelectValue>{(value) => getStatusLabel(value)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="OPEN"></SelectItem>
<SelectItem value="USER_REPLIED"></SelectItem>
<SelectItem value="ADMIN_REPLIED"></SelectItem>
<SelectItem value="CLOSED"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="min-w-40 space-y-2">
<Label htmlFor="priority"></Label>
<Select
name="priority"
value={selectedPriority}
onValueChange={(value) => setSelectedPriority((value ?? "NORMAL") as SupportTicketPriority)}
>
<SelectTrigger id="priority" className="w-full">
<SelectValue>{(value) => getPriorityLabel(value)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="LOW"></SelectItem>
<SelectItem value="NORMAL"></SelectItem>
<SelectItem value="HIGH"></SelectItem>
<SelectItem value="URGENT"></SelectItem>
</SelectContent>
</Select>
</div>
</>
);
}

View File

@@ -6,6 +6,13 @@ import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
@@ -54,11 +61,11 @@ export function UserForm({
<DialogTitle>{isEdit ? "编辑用户" : "创建用户"}</DialogTitle>
</DialogHeader>
<form action={handleSubmit} className="space-y-5">
<div>
<div className="space-y-2">
<Label></Label>
<Input name="email" type="email" defaultValue={user?.email} required />
</div>
<div>
<div className="space-y-2">
<Label>{isEdit ? "新密码(可留空)" : "密码"}</Label>
<Input
name="password"
@@ -68,20 +75,24 @@ export function UserForm({
placeholder={isEdit ? "留空则保持不变" : undefined}
/>
</div>
<div>
<div className="space-y-2">
<Label></Label>
<Input name="name" defaultValue={user?.name ?? ""} />
</div>
<div>
<Label></Label>
<select
<div className="space-y-2">
<Label htmlFor="user-role"></Label>
<Select
name="role"
defaultValue={user?.role ?? "USER"}
className="h-10 w-full px-3 text-sm outline-none"
>
<option value="USER"></option>
<option value="ADMIN"></option>
</select>
<SelectTrigger id="user-role" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="USER"></SelectItem>
<SelectItem value="ADMIN"></SelectItem>
</SelectContent>
</Select>
</div>
<PendingSubmitButton className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
{isEdit ? "保存" : "创建"}