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 { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
@@ -22,6 +23,13 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { getErrorMessage } from "@/lib/errors"; import { getErrorMessage } from "@/lib/errors";
@@ -43,6 +51,32 @@ interface AnnouncementFormData {
endAt: 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) { function toDateTimeLocalValue(value: Date | string | null) {
if (!value) { if (!value) {
return ""; return "";
@@ -94,48 +128,55 @@ export function AnnouncementForm({
<DialogTrigger render={<Button variant={triggerVariant} size="sm" />}> <DialogTrigger render={<Button variant={triggerVariant} size="sm" />}>
{triggerLabel ?? "编辑"} {triggerLabel ?? "编辑"}
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden p-0 sm:max-w-[30rem]">
<DialogHeader> <DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
</DialogHeader> </DialogHeader>
<form action={handleSubmit} className="space-y-4"> <DialogBody className="flex-1 px-4 py-3">
<div className="grid gap-4 md:grid-cols-2"> <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="space-y-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`title-${announcement.id}`}></Label> <Label htmlFor={`title-${announcement.id}`}></Label>
<Input id={`title-${announcement.id}`} name="title" defaultValue={announcement.title} required /> <Input id={`title-${announcement.id}`} name="title" defaultValue={announcement.title} required />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`audience-${announcement.id}`}></Label> <Label htmlFor={`audience-${announcement.id}`}></Label>
<select <Select
id={`audience-${announcement.id}`}
name="audience" name="audience"
defaultValue={announcement.audience} value={audience}
onChange={(event) => setAudience(event.target.value as AnnouncementAudience)} onValueChange={(value) => setAudience((value ?? "USERS") as AnnouncementAudience)}
className="h-10 w-full px-3 text-sm outline-none"
> >
<option value="PUBLIC">/</option> <SelectTrigger id={`audience-${announcement.id}`} className="w-full">
<option value="USERS"></option> <SelectValue>{(value) => getAudienceLabel(value)}</SelectValue>
<option value="ADMINS"></option> </SelectTrigger>
<option value="SPECIFIC_USER"></option> <SelectContent align="start">
</select> <SelectItem value="PUBLIC">/</SelectItem>
<SelectItem value="USERS"></SelectItem>
<SelectItem value="ADMINS"></SelectItem>
<SelectItem value="SPECIFIC_USER"></SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`displayType-${announcement.id}`}></Label> <Label htmlFor={`displayType-${announcement.id}`}></Label>
<select <Select
id={`displayType-${announcement.id}`}
name="displayType" name="displayType"
defaultValue={announcement.displayType} defaultValue={announcement.displayType}
className="h-10 w-full px-3 text-sm outline-none"
> >
<option value="INLINE"></option> <SelectTrigger id={`displayType-${announcement.id}`} className="w-full">
<option value="BIG"></option> <SelectValue>{(value) => getDisplayTypeLabel(value)}</SelectValue>
<option value="POPUP"></option> </SelectTrigger>
</select> <SelectContent align="start">
<SelectItem value="INLINE"></SelectItem>
<SelectItem value="BIG"></SelectItem>
<SelectItem value="POPUP"></SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`dismissible-${announcement.id}`}></Label> <Label htmlFor={`dismissible-${announcement.id}`}></Label>
<BooleanToggle <BooleanToggle
id={`dismissible-${announcement.id}`} id={`dismissible-${announcement.id}`}
@@ -144,29 +185,33 @@ export function AnnouncementForm({
trueLabel="允许" trueLabel="允许"
falseLabel="不允许" falseLabel="不允许"
ariaLabel="允许用户关闭" ariaLabel="允许用户关闭"
size="compact"
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`targetUserId-${announcement.id}`}></Label> <Label htmlFor={`targetUserId-${announcement.id}`}></Label>
<select <Select
id={`targetUserId-${announcement.id}`}
name="targetUserId" name="targetUserId"
defaultValue={announcement.targetUserId ?? ""} defaultValue={announcement.targetUserId ?? ""}
disabled={audience !== "SPECIFIC_USER"} 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> <SelectTrigger id={`targetUserId-${announcement.id}`} className="w-full">
{users.map((user) => ( <SelectValue>{(value) => getTargetUserLabel(users, value)}</SelectValue>
<option key={user.id} value={user.id}> </SelectTrigger>
{user.email} <SelectContent align="start">
</option> <SelectItem value=""></SelectItem>
))} {users.map((user) => (
</select> <SelectItem key={user.id} value={user.id}>
{user.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`body-${announcement.id}`}></Label> <Label htmlFor={`body-${announcement.id}`}></Label>
<Textarea <Textarea
id={`body-${announcement.id}`} id={`body-${announcement.id}`}
@@ -177,8 +222,8 @@ export function AnnouncementForm({
/> />
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`startAt-${announcement.id}`}></Label> <Label htmlFor={`startAt-${announcement.id}`}></Label>
<Input <Input
id={`startAt-${announcement.id}`} id={`startAt-${announcement.id}`}
@@ -187,7 +232,7 @@ export function AnnouncementForm({
defaultValue={toDateTimeLocalValue(announcement.startAt)} defaultValue={toDateTimeLocalValue(announcement.startAt)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`endAt-${announcement.id}`}></Label> <Label htmlFor={`endAt-${announcement.id}`}></Label>
<Input <Input
id={`endAt-${announcement.id}`} id={`endAt-${announcement.id}`}
@@ -198,7 +243,7 @@ export function AnnouncementForm({
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`sendNotification-${announcement.id}`}></Label> <Label htmlFor={`sendNotification-${announcement.id}`}></Label>
<BooleanToggle <BooleanToggle
id={`sendNotification-${announcement.id}`} id={`sendNotification-${announcement.id}`}
@@ -207,13 +252,15 @@ export function AnnouncementForm({
trueLabel="发送" trueLabel="发送"
falseLabel="不发送" falseLabel="不发送"
ariaLabel="同步发送站内通知" ariaLabel="同步发送站内通知"
size="compact"
/> />
</div> </div>
<PendingSubmitButton className="w-full" pendingLabel="保存中..."> <PendingSubmitButton className="h-8 w-full" pendingLabel="保存中...">
</PendingSubmitButton> </PendingSubmitButton>
</form> </form>
</DialogBody>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -239,50 +286,65 @@ export function CreateAnnouncementButton({
} }
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
setAudience("USERS");
}
setOpen(nextOpen);
}}
>
<DialogTrigger render={<Button />}></DialogTrigger> <DialogTrigger render={<Button />}></DialogTrigger>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden p-0 sm:max-w-[30rem]">
<DialogHeader> <DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
</DialogHeader> </DialogHeader>
<form action={handleSubmit} className="space-y-4"> <DialogBody className="flex-1 px-4 py-3">
<div className="grid gap-4 md:grid-cols-2"> <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="space-y-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="create-announcement-title"></Label> <Label htmlFor="create-announcement-title"></Label>
<Input id="create-announcement-title" name="title" required /> <Input id="create-announcement-title" name="title" required />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-audience"></Label> <Label htmlFor="create-announcement-audience"></Label>
<select <Select
id="create-announcement-audience"
name="audience" name="audience"
defaultValue="USERS" value={audience}
onChange={(event) => setAudience(event.target.value as AnnouncementAudience)} onValueChange={(value) => setAudience((value ?? "USERS") as AnnouncementAudience)}
className="h-10 w-full px-3 text-sm outline-none"
> >
<option value="PUBLIC">/</option> <SelectTrigger id="create-announcement-audience" className="w-full">
<option value="USERS"></option> <SelectValue>{(value) => getAudienceLabel(value)}</SelectValue>
<option value="ADMINS"></option> </SelectTrigger>
<option value="SPECIFIC_USER"></option> <SelectContent align="start">
</select> <SelectItem value="PUBLIC">/</SelectItem>
<SelectItem value="USERS"></SelectItem>
<SelectItem value="ADMINS"></SelectItem>
<SelectItem value="SPECIFIC_USER"></SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-displayType"></Label> <Label htmlFor="create-announcement-displayType"></Label>
<select <Select
id="create-announcement-displayType"
name="displayType" name="displayType"
defaultValue="INLINE" defaultValue="INLINE"
className="h-10 w-full px-3 text-sm outline-none"
> >
<option value="INLINE"></option> <SelectTrigger id="create-announcement-displayType" className="w-full">
<option value="BIG"></option> <SelectValue>{(value) => getDisplayTypeLabel(value)}</SelectValue>
<option value="POPUP"></option> </SelectTrigger>
</select> <SelectContent align="start">
<SelectItem value="INLINE"></SelectItem>
<SelectItem value="BIG"></SelectItem>
<SelectItem value="POPUP"></SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-dismissible"></Label> <Label htmlFor="create-announcement-dismissible"></Label>
<BooleanToggle <BooleanToggle
id="create-announcement-dismissible" id="create-announcement-dismissible"
@@ -291,45 +353,49 @@ export function CreateAnnouncementButton({
trueLabel="允许" trueLabel="允许"
falseLabel="不允许" falseLabel="不允许"
ariaLabel="允许用户关闭" ariaLabel="允许用户关闭"
size="compact"
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-targetUserId"></Label> <Label htmlFor="create-announcement-targetUserId"></Label>
<select <Select
id="create-announcement-targetUserId"
name="targetUserId" name="targetUserId"
defaultValue="" defaultValue=""
disabled={audience !== "SPECIFIC_USER"} 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> <SelectTrigger id="create-announcement-targetUserId" className="w-full">
{users.map((user) => ( <SelectValue>{(value) => getTargetUserLabel(users, value)}</SelectValue>
<option key={user.id} value={user.id}> </SelectTrigger>
{user.email} <SelectContent align="start">
</option> <SelectItem value=""></SelectItem>
))} {users.map((user) => (
</select> <SelectItem key={user.id} value={user.id}>
{user.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-body"></Label> <Label htmlFor="create-announcement-body"></Label>
<Textarea id="create-announcement-body" name="body" rows={5} required /> <Textarea id="create-announcement-body" name="body" rows={5} required />
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-startAt"></Label> <Label htmlFor="create-announcement-startAt"></Label>
<Input id="create-announcement-startAt" name="startAt" type="datetime-local" /> <Input id="create-announcement-startAt" name="startAt" type="datetime-local" />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-endAt"></Label> <Label htmlFor="create-announcement-endAt"></Label>
<Input id="create-announcement-endAt" name="endAt" type="datetime-local" /> <Input id="create-announcement-endAt" name="endAt" type="datetime-local" />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-sendNotification"></Label> <Label htmlFor="create-announcement-sendNotification"></Label>
<BooleanToggle <BooleanToggle
id="create-announcement-sendNotification" id="create-announcement-sendNotification"
@@ -338,13 +404,15 @@ export function CreateAnnouncementButton({
trueLabel="发送" trueLabel="发送"
falseLabel="不发送" falseLabel="不发送"
ariaLabel="同步发送站内通知" ariaLabel="同步发送站内通知"
size="compact"
/> />
</div> </div>
<PendingSubmitButton className="w-full" pendingLabel="发布中..."> <PendingSubmitButton className="h-8 w-full" pendingLabel="发布中...">
</PendingSubmitButton> </PendingSubmitButton>
</form> </form>
</DialogBody>
</DialogContent> </DialogContent>
</Dialog> </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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getCommerceData } from "./commerce-data"; import { getCommerceData } from "./commerce-data";
import { CommerceToggleButton } from "./_components/commerce-actions"; 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 = { export const metadata: Metadata = {
title: "商业配置", title: "商业配置",
@@ -51,10 +58,7 @@ export default async function AdminCommercePage() {
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="coupon-type"></Label> <Label htmlFor="coupon-type"></Label>
<select id="coupon-type" name="discountType" className={selectClassName} defaultValue="AMOUNT_OFF"> <DiscountTypeSelect />
<option value="AMOUNT_OFF"></option>
<option value="PERCENT_OFF"></option>
</select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="coupon-value"></Label> <Label htmlFor="coupon-value"></Label>
@@ -123,7 +127,7 @@ export default async function AdminCommercePage() {
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<StatusBadge tone="warning"> <StatusBadge tone="warning">
{coupon.discountType === "PERCENT_OFF" ? `${Number(coupon.discountValue)}%` : `¥${Number(coupon.discountValue).toFixed(2)}`} {formatCouponDiscount(coupon.discountType, coupon.discountValue)}
</StatusBadge> </StatusBadge>
<StatusBadge>{coupon.thresholdAmount == null ? "无门槛" : `满 ¥${Number(coupon.thresholdAmount).toFixed(2)}`}</StatusBadge> <StatusBadge>{coupon.thresholdAmount == null ? "无门槛" : `满 ¥${Number(coupon.thresholdAmount).toFixed(2)}`}</StatusBadge>
<StatusBadge>{coupon.isPublic ? "公开展示" : "仅发放"}</StatusBadge> <StatusBadge>{coupon.isPublic ? "公开展示" : "仅发放"}</StatusBadge>

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,14 +44,14 @@ function PolicyToggleRow({
children: ReactNode; children: ReactNode;
}) { }) {
return ( 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"> <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} {label}
</p> </p>
<InlineHelp align="start">{help}</InlineHelp> <InlineHelp align="start">{help}</InlineHelp>
</div> </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> </div>
); );
} }
@@ -71,7 +71,7 @@ export function PlanPolicySection({
}: PlanPolicySectionProps) { }: PlanPolicySectionProps) {
return ( 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 <PolicyToggleRow
labelId={fieldId("allowRenewal-label")} labelId={fieldId("allowRenewal-label")}
label="开放续费" label="开放续费"
@@ -83,6 +83,7 @@ export function PlanPolicySection({
trueLabel="开放" trueLabel="开放"
falseLabel="关闭" falseLabel="关闭"
ariaLabel="开放续费" ariaLabel="开放续费"
size="compact"
/> />
</PolicyToggleRow> </PolicyToggleRow>
{type === "PROXY" && ( {type === "PROXY" && (
@@ -97,15 +98,16 @@ export function PlanPolicySection({
trueLabel="开放" trueLabel="开放"
falseLabel="关闭" falseLabel="关闭"
ariaLabel="开放增流量" ariaLabel="开放增流量"
size="compact"
/> />
</PolicyToggleRow> </PolicyToggleRow>
)} )}
</div> </div>
{allowRenewal && ( {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 className="grid gap-3 sm:grid-cols-2">
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("renewalPricingMode")}></Label> <Label htmlFor={fieldId("renewalPricingMode")}></Label>
<input type="hidden" name="renewalPricingMode" value={renewalPricingMode} /> <input type="hidden" name="renewalPricingMode" value={renewalPricingMode} />
<Select value={renewalPricingMode} onValueChange={(value) => setRenewalPricingMode(value as RenewalPricingMode)}> <Select value={renewalPricingMode} onValueChange={(value) => setRenewalPricingMode(value as RenewalPricingMode)}>
@@ -120,7 +122,7 @@ export function PlanPolicySection({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("renewalPrice")}> <Label htmlFor={fieldId("renewalPrice")}>
{renewalPricingMode === "PER_DAY" ? "续费价格(¥/天)" : "续费价格(¥/周期)"} {renewalPricingMode === "PER_DAY" ? "续费价格(¥/天)" : "续费价格(¥/周期)"}
</Label> </Label>
@@ -139,7 +141,7 @@ export function PlanPolicySection({
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{renewalPricingMode === "FIXED_DURATION" ? ( {renewalPricingMode === "FIXED_DURATION" ? (
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("renewalDurationDays")}></Label> <Label htmlFor={fieldId("renewalDurationDays")}></Label>
<Input <Input
id={fieldId("renewalDurationDays")} id={fieldId("renewalDurationDays")}
@@ -153,7 +155,7 @@ export function PlanPolicySection({
</div> </div>
) : ( ) : (
<> <>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("renewalMinDays")}></Label> <Label htmlFor={fieldId("renewalMinDays")}></Label>
<Input <Input
id={fieldId("renewalMinDays")} id={fieldId("renewalMinDays")}
@@ -164,7 +166,7 @@ export function PlanPolicySection({
placeholder="1" placeholder="1"
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("renewalMaxDays")}></Label> <Label htmlFor={fieldId("renewalMaxDays")}></Label>
<Input <Input
id={fieldId("renewalMaxDays")} id={fieldId("renewalMaxDays")}
@@ -182,9 +184,9 @@ export function PlanPolicySection({
)} )}
{type === "PROXY" && allowTrafficTopup && ( {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 className="grid gap-3 sm:grid-cols-2">
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("topupPricingMode")}></Label> <Label htmlFor={fieldId("topupPricingMode")}></Label>
<input type="hidden" name="topupPricingMode" value={topupPricingMode} /> <input type="hidden" name="topupPricingMode" value={topupPricingMode} />
<Select value={topupPricingMode} onValueChange={(value) => setTopupPricingMode(value as TopupPricingMode)}> <Select value={topupPricingMode} onValueChange={(value) => setTopupPricingMode(value as TopupPricingMode)}>
@@ -200,7 +202,7 @@ export function PlanPolicySection({
</Select> </Select>
</div> </div>
{topupPricingMode === "PER_GB" ? ( {topupPricingMode === "PER_GB" ? (
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("topupPricePerGb")}>¥/GB</Label> <Label htmlFor={fieldId("topupPricePerGb")}>¥/GB</Label>
<Input <Input
id={fieldId("topupPricePerGb")} id={fieldId("topupPricePerGb")}
@@ -214,7 +216,7 @@ export function PlanPolicySection({
/> />
</div> </div>
) : ( ) : (
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("topupFixedPrice")}>¥</Label> <Label htmlFor={fieldId("topupFixedPrice")}>¥</Label>
<Input <Input
id={fieldId("topupFixedPrice")} id={fieldId("topupFixedPrice")}
@@ -231,7 +233,7 @@ export function PlanPolicySection({
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("minTopupGb")}>GB</Label> <Label htmlFor={fieldId("minTopupGb")}>GB</Label>
<Input <Input
id={fieldId("minTopupGb")} id={fieldId("minTopupGb")}
@@ -242,7 +244,7 @@ export function PlanPolicySection({
placeholder="1" placeholder="1"
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("maxTopupGb")}>GB</Label> <Label htmlFor={fieldId("maxTopupGb")}>GB</Label>
<Input <Input
id={fieldId("maxTopupGb")} id={fieldId("maxTopupGb")}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,44 +1,17 @@
import { updateSupportTicketMeta } from "@/actions/admin/support"; import { updateSupportTicketMeta } from "@/actions/admin/support";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Label } from "@/components/ui/label";
import type { AdminSupportTicketDetail } from "../support-data"; import type { AdminSupportTicketDetail } from "../support-data";
import { SupportTicketMetaSelects } from "./support-ticket-meta-selects";
export function SupportTicketMetaForm({ ticket }: { ticket: AdminSupportTicketDetail }) { export function SupportTicketMetaForm({ ticket }: { ticket: AdminSupportTicketDetail }) {
return ( return (
<form <form
action={updateSupportTicketMeta} 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} /> <input type="hidden" name="ticketId" value={ticket.id} />
<div className="space-y-2"> <SupportTicketMetaSelects status={ticket.status} priority={ticket.priority} />
<Label htmlFor="status"></Label> <PendingSubmitButton variant="outline" size="lg" className="w-full sm:w-auto" pendingLabel="更新中..."></PendingSubmitButton>
<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>
</form> </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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -54,11 +61,11 @@ export function UserForm({
<DialogTitle>{isEdit ? "编辑用户" : "创建用户"}</DialogTitle> <DialogTitle>{isEdit ? "编辑用户" : "创建用户"}</DialogTitle>
</DialogHeader> </DialogHeader>
<form action={handleSubmit} className="space-y-5"> <form action={handleSubmit} className="space-y-5">
<div> <div className="space-y-2">
<Label></Label> <Label></Label>
<Input name="email" type="email" defaultValue={user?.email} required /> <Input name="email" type="email" defaultValue={user?.email} required />
</div> </div>
<div> <div className="space-y-2">
<Label>{isEdit ? "新密码(可留空)" : "密码"}</Label> <Label>{isEdit ? "新密码(可留空)" : "密码"}</Label>
<Input <Input
name="password" name="password"
@@ -68,20 +75,24 @@ export function UserForm({
placeholder={isEdit ? "留空则保持不变" : undefined} placeholder={isEdit ? "留空则保持不变" : undefined}
/> />
</div> </div>
<div> <div className="space-y-2">
<Label></Label> <Label></Label>
<Input name="name" defaultValue={user?.name ?? ""} /> <Input name="name" defaultValue={user?.name ?? ""} />
</div> </div>
<div> <div className="space-y-2">
<Label></Label> <Label htmlFor="user-role"></Label>
<select <Select
name="role" name="role"
defaultValue={user?.role ?? "USER"} defaultValue={user?.role ?? "USER"}
className="h-10 w-full px-3 text-sm outline-none"
> >
<option value="USER"></option> <SelectTrigger id="user-role" className="w-full">
<option value="ADMIN"></option> <SelectValue />
</select> </SelectTrigger>
<SelectContent align="start">
<SelectItem value="USER"></SelectItem>
<SelectItem value="ADMIN"></SelectItem>
</SelectContent>
</Select>
</div> </div>
<PendingSubmitButton className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}> <PendingSubmitButton className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
{isEdit ? "保存" : "创建"} {isEdit ? "保存" : "创建"}

View File

@@ -74,7 +74,7 @@ export function LatencyDetailDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] overflow-y-auto"> <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-[34rem]">
<DialogHeader> <DialogHeader>
<DialogTitle className="inline-flex items-center gap-2"> <DialogTitle className="inline-flex items-center gap-2">
<Activity className="size-4 text-primary" /> <Activity className="size-4 text-primary" />

View File

@@ -98,7 +98,7 @@ export function ProxyDetailDialog({ open, onOpenChange, plan, networkInsightsEna
return ( return (
<> <>
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"> <DialogContent className="flex max-h-[90vh] flex-col overflow-hidden sm:max-w-[39rem]">
<DialogHeader> <DialogHeader>
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-2.5 py-1 text-[0.68rem] font-semibold tracking-[0.14em] text-primary"> <div className="inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-2.5 py-1 text-[0.68rem] font-semibold tracking-[0.14em] text-primary">
<Network className="size-3.5" /> PROXY <Network className="size-3.5" /> PROXY
@@ -109,9 +109,9 @@ export function ProxyDetailDialog({ open, onOpenChange, plan, networkInsightsEna
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="overflow-y-auto -mx-6 px-6 space-y-4"> <div className="-mx-4 space-y-3 overflow-y-auto px-4">
{/* Compact info row — above the grid so both columns align */} {/* Compact info row — above the grid so both columns align */}
<div className="flex flex-wrap gap-2 text-sm"> <div className="flex flex-wrap gap-2 text-xs">
<span className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/20 px-2.5 py-1.5"> <span className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/20 px-2.5 py-1.5">
<Server className="size-3.5 text-primary" /> <Server className="size-3.5 text-primary" />
{plan.nodeName} {plan.nodeName}
@@ -122,7 +122,7 @@ export function ProxyDetailDialog({ open, onOpenChange, plan, networkInsightsEna
</span> </span>
</div> </div>
<div className={`grid items-start gap-6 ${networkInsightsEnabled ? "lg:grid-cols-[1fr_20rem]" : ""}`}> <div className={`grid items-start gap-4 ${networkInsightsEnabled ? "lg:grid-cols-[1fr_14rem]" : ""}`}>
{/* Left: purchase config — always visible without scrolling */} {/* Left: purchase config — always visible without scrolling */}
<div className="space-y-3"> <div className="space-y-3">
<ProxyInboundSelect <ProxyInboundSelect
@@ -154,9 +154,8 @@ export function ProxyDetailDialog({ open, onOpenChange, plan, networkInsightsEna
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
size="lg"
variant="outline" variant="outline"
className="flex-1" className="h-8 flex-1"
onClick={handleAddToCart} onClick={handleAddToCart}
disabled={cartLoading || !plan.isAvailable || !hasInboundOptions} disabled={cartLoading || !plan.isAvailable || !hasInboundOptions}
> >
@@ -164,8 +163,7 @@ export function ProxyDetailDialog({ open, onOpenChange, plan, networkInsightsEna
{cartLoading ? "正在加入..." : "加入购物车"} {cartLoading ? "正在加入..." : "加入购物车"}
</Button> </Button>
<Button <Button
size="lg" className="h-8 flex-1"
className="flex-1"
onClick={handlePurchase} onClick={handlePurchase}
disabled={loading || !plan.isAvailable || !hasInboundOptions} disabled={loading || !plan.isAvailable || !hasInboundOptions}
> >
@@ -175,7 +173,7 @@ export function ProxyDetailDialog({ open, onOpenChange, plan, networkInsightsEna
</div> </div>
{!plan.isAvailable && ( {!plan.isAvailable && (
<Button variant="outline" size="lg" className="w-full" onClick={checkAvailability} disabled={checking}> <Button variant="outline" className="h-8 w-full" onClick={checkAvailability} disabled={checking}>
{checking ? "查询中..." : "查看补位时间"} {checking ? "查询中..." : "查看补位时间"}
</Button> </Button>
)} )}

View File

@@ -17,7 +17,7 @@ interface ProxyTraceDetailDialogProps {
export function ProxyTraceDetailDialog({ trace, onOpenChange }: ProxyTraceDetailDialogProps) { export function ProxyTraceDetailDialog({ trace, onOpenChange }: ProxyTraceDetailDialogProps) {
return ( return (
<Dialog open={trace !== null} onOpenChange={onOpenChange}> <Dialog open={trace !== null} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto"> <DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-[30rem]">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{trace ? `${getCarrierLabel(trace.carrier)} 路由 — ${trace.summary}` : "路由详情"} {trace ? `${getCarrierLabel(trace.carrier)} 路由 — ${trace.summary}` : "路由详情"}

View File

@@ -60,7 +60,7 @@ export function StreamingDetailDialog({ open, onOpenChange, plan }: Props) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col"> <DialogContent className="flex max-h-[90vh] max-w-[30rem] flex-col overflow-hidden">
<DialogHeader> <DialogHeader>
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-amber-500/15 bg-amber-500/10 px-2.5 py-1 text-[0.68rem] font-semibold tracking-[0.14em] text-amber-700 dark:text-amber-300"> <div className="inline-flex w-fit items-center gap-2 rounded-full border border-amber-500/15 bg-amber-500/10 px-2.5 py-1 text-[0.68rem] font-semibold tracking-[0.14em] text-amber-700 dark:text-amber-300">
<Film className="size-3.5" /> STREAMING <Film className="size-3.5" /> STREAMING
@@ -71,7 +71,7 @@ export function StreamingDetailDialog({ open, onOpenChange, plan }: Props) {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="overflow-y-auto -mx-6 px-6 space-y-5"> <div className="-mx-4 space-y-3 overflow-y-auto px-4">
{plan.description && ( {plan.description && (
<div> <div>
<p className="mb-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground"> <p className="mb-2 text-xs font-semibold tracking-[0.14em] text-muted-foreground">
@@ -102,7 +102,7 @@ export function StreamingDetailDialog({ open, onOpenChange, plan }: Props) {
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
<Button <Button
size="lg" className="h-8"
variant="outline" variant="outline"
onClick={handleAddToCart} onClick={handleAddToCart}
disabled={cartLoading || !plan.isAvailable} disabled={cartLoading || !plan.isAvailable}
@@ -111,7 +111,7 @@ export function StreamingDetailDialog({ open, onOpenChange, plan }: Props) {
{cartLoading ? "正在加入..." : "加入购物车"} {cartLoading ? "正在加入..." : "加入购物车"}
</Button> </Button>
<Button <Button
size="lg" className="h-8"
onClick={handlePurchase} onClick={handlePurchase}
disabled={loading || !plan.isAvailable} disabled={loading || !plan.isAvailable}
> >
@@ -121,9 +121,8 @@ export function StreamingDetailDialog({ open, onOpenChange, plan }: Props) {
{!plan.isAvailable && ( {!plan.isAvailable && (
<Button <Button
size="lg"
variant="outline" variant="outline"
className="w-full" className="h-8 w-full"
onClick={checkAvailability} onClick={checkAvailability}
disabled={checking} disabled={checking}
> >

View File

@@ -87,7 +87,7 @@ export function RenewalButton({
</Button> </Button>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-[24rem]">
<DialogHeader> <DialogHeader>
<div className="mb-1 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"> <div className="mb-1 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">
<WalletCards className="size-3.5" /> RENEWAL <WalletCards className="size-3.5" /> RENEWAL

View File

@@ -99,7 +99,7 @@ export function TrafficTopupDialog({
</Button> </Button>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-[24rem]">
<DialogHeader> <DialogHeader>
<div className="mb-1 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"> <div className="mb-1 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">
<WalletCards className="size-3.5" /> TRAFFIC TOPUP <WalletCards className="size-3.5" /> TRAFFIC TOPUP

View File

@@ -8,8 +8,16 @@ import { createSupportTicket } from "@/actions/user/support";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { getErrorMessage } from "@/lib/errors"; import { getErrorMessage } from "@/lib/errors";
import { supportTicketPriorityLabels } from "@/services/support-labels";
const ATTACHMENT_ACCEPT = "image/jpeg,image/png,image/webp,image/gif,image/avif"; const ATTACHMENT_ACCEPT = "image/jpeg,image/png,image/webp,image/gif,image/avif";
@@ -21,6 +29,12 @@ type SupportTicketPreset = {
body?: string; body?: string;
}; };
type SupportTicketPriorityValue = NonNullable<SupportTicketPreset["priority"]>;
function getPriorityLabel(value: unknown) {
return supportTicketPriorityLabels[value as SupportTicketPriorityValue] ?? "选择优先级";
}
export function CreateSupportTicketForm({ export function CreateSupportTicketForm({
defaultOpen = false, defaultOpen = false,
openTicketCount = 0, openTicketCount = 0,
@@ -35,6 +49,9 @@ export function CreateSupportTicketForm({
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [priority, setPriority] = useState<SupportTicketPriorityValue>(
preset?.priority ?? "NORMAL",
);
const submittingRef = useRef(false); const submittingRef = useRef(false);
const effectiveOpenTicketLimit = Math.max(1, openTicketLimit); const effectiveOpenTicketLimit = Math.max(1, openTicketLimit);
const limitReached = openTicketCount >= effectiveOpenTicketLimit; const limitReached = openTicketCount >= effectiveOpenTicketLimit;
@@ -52,6 +69,7 @@ export function CreateSupportTicketForm({
await createSupportTicket(formData); await createSupportTicket(formData);
toast.success("工单已提交"); toast.success("工单已提交");
form.reset(); form.reset();
setPriority(preset?.priority ?? "NORMAL");
setOpen(false); setOpen(false);
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
@@ -120,17 +138,22 @@ export function CreateSupportTicketForm({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="priority"></Label> <Label htmlFor="priority"></Label>
<select <Select
id="priority"
name="priority" name="priority"
defaultValue={preset?.priority ?? "NORMAL"} value={priority}
className="h-11 w-full px-3 text-sm outline-none disabled:cursor-not-allowed" onValueChange={(value) => setPriority((value ?? "NORMAL") as SupportTicketPriorityValue)}
disabled={submitting}
> >
<option value="LOW"></option> <SelectTrigger id="priority" className="w-full">
<option value="NORMAL"></option> <SelectValue>{(value) => getPriorityLabel(value)}</SelectValue>
<option value="HIGH"></option> </SelectTrigger>
<option value="URGENT"></option> <SelectContent align="start">
</select> <SelectItem value="LOW"></SelectItem>
<SelectItem value="NORMAL"></SelectItem>
<SelectItem value="HIGH"></SelectItem>
<SelectItem value="URGENT"></SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -4,6 +4,13 @@ import { useMemo, useState, type ReactNode } from "react";
import { SlidersHorizontal, X } from "lucide-react"; import { SlidersHorizontal, X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface AdminFilterOption { export interface AdminFilterOption {
@@ -15,6 +22,18 @@ export interface AdminFilterSelect {
name: string; name: string;
value: string; value: string;
options: AdminFilterOption[]; options: AdminFilterOption[];
label?: string;
}
function getFilterSelectLabel(select: AdminFilterSelect) {
if (select.label) return select.label;
const firstLabel = select.options[0]?.label;
if (!firstLabel) return select.name;
return firstLabel.replace(/^全部/, "") || firstLabel;
}
function getFilterOptionLabel(select: AdminFilterSelect, value: string | null | undefined) {
return select.options.find((option) => option.value === (value ?? ""))?.label ?? select.options[0]?.label ?? "全部";
} }
export function AdminFilterBar({ export function AdminFilterBar({
@@ -36,7 +55,7 @@ export function AdminFilterBar({
const [mobileOpen, setMobileOpen] = useState(activeFilterCount > 0); const [mobileOpen, setMobileOpen] = useState(activeFilterCount > 0);
return ( return (
<form className="surface-card rounded-xl p-3" role="search"> <form className="surface-card rounded-xl p-3 sm:p-4" role="search">
<div className="flex items-center justify-between gap-3 md:hidden"> <div className="flex items-center justify-between gap-3 md:hidden">
<button <button
type="button" type="button"
@@ -55,40 +74,57 @@ export function AdminFilterBar({
mobileOpen ? "mt-3 flex" : "hidden", mobileOpen ? "mt-3 flex" : "hidden",
)} )}
> >
<div className="min-w-0 md:min-w-[16rem] md:flex-[1_1_18rem]"> <div className="min-w-0 space-y-1.5 md:min-w-[16rem] md:flex-[1_1_18rem]">
<label className="sr-only" htmlFor="admin-filter-search"> <label className="flex items-center gap-1.5 px-1 text-xs font-semibold whitespace-nowrap text-muted-foreground" htmlFor="admin-filter-search">
{searchPlaceholder ?? "搜索"}
</label> </label>
<Input <Input
id="admin-filter-search" id="admin-filter-search"
name="q" name="q"
defaultValue={q ?? ""} defaultValue={q ?? ""}
placeholder={searchPlaceholder ?? "搜索"} placeholder={searchPlaceholder ?? "搜索"}
className="h-10 md:h-11" className="h-10 bg-muted/30 shadow-[var(--shadow-button)]"
/> />
</div> </div>
{selects.map((select) => ( {selects.map((select) => (
<div key={select.name} className="md:min-w-[11rem] md:flex-[1_1_11rem]"> <div key={select.name} className="space-y-1.5 md:min-w-[12rem] md:flex-[0_1_13rem]">
<label className="sr-only" htmlFor={`admin-filter-${select.name}`}> <label
{select.options[0]?.label ?? select.name} className="flex items-center gap-1.5 px-1 text-xs font-semibold whitespace-nowrap text-muted-foreground"
htmlFor={`admin-filter-${select.name}`}
>
{getFilterSelectLabel(select)}
</label> </label>
<select <Select
id={`admin-filter-${select.name}`} key={`${select.name}-${select.value}`}
name={select.name} name={select.name}
defaultValue={select.value} defaultValue={select.value}
className="h-10 w-full px-3 text-sm outline-none md:h-11"
> >
{select.options.map((option) => ( <SelectTrigger
<option key={option.value} value={option.value}> id={`admin-filter-${select.name}`}
{option.label} className={cn(
</option> "h-10 w-full justify-between rounded-lg px-3",
))} select.value && "border-primary/35 bg-primary/10 text-primary",
</select> )}
>
<SelectValue placeholder={select.options[0]?.label ?? "全部"}>
{(value) => getFilterOptionLabel(select, value == null ? "" : String(value))}
</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{select.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
))} ))}
<Button type="submit" className="h-10 md:h-11 md:flex-none"> <div className="md:self-end">
<Button type="submit" className="h-10 w-full md:w-auto">
</Button>
</Button>
</div>
{children && <div className="hidden md:block">{children}</div>} {children && <div className="hidden md:block">{children}</div>}
</div> </div>
</form> </form>

View File

@@ -82,7 +82,7 @@ export function ConfirmActionButton({
{children} {children}
</Button> </Button>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-[20rem]">
<DialogHeader> <DialogHeader>
<div className="mb-1 flex size-9 items-center justify-center rounded-lg border border-destructive/15 bg-destructive/10 text-destructive"> <div className="mb-1 flex size-9 items-center justify-center rounded-lg border border-destructive/15 bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" /> <AlertTriangle className="size-5" />

View File

@@ -277,7 +277,7 @@ export function SubscriptionRiskReviewActions({
</div> </div>
<Dialog open={dialog != null} onOpenChange={(open) => !pending && !open && setDialog(null)}> <Dialog open={dialog != null} onOpenChange={(open) => !pending && !open && setDialog(null)}>
<DialogContent className={dialog?.type === "report" ? "sm:max-w-3xl" : "sm:max-w-lg"}> <DialogContent className={dialog?.type === "report" ? "sm:max-w-[34rem]" : "sm:max-w-[24rem]"}>
{dialog?.type === "review" && ( {dialog?.type === "review" && (
<> <>
<DialogHeader> <DialogHeader>

View File

@@ -13,6 +13,7 @@ export interface BooleanToggleProps {
falseLabel?: string; falseLabel?: string;
ariaLabel?: string; ariaLabel?: string;
className?: string; className?: string;
size?: "default" | "compact";
disabled?: boolean; disabled?: boolean;
} }
@@ -26,6 +27,7 @@ export function BooleanToggle({
falseLabel = "关闭", falseLabel = "关闭",
ariaLabel, ariaLabel,
className, className,
size = "default",
disabled = false, disabled = false,
}: BooleanToggleProps) { }: BooleanToggleProps) {
const generatedId = useId(); const generatedId = useId();
@@ -46,7 +48,11 @@ export function BooleanToggle({
<div <div
role="group" role="group"
aria-label={ariaLabel} aria-label={ariaLabel}
className="inline-flex min-h-10 w-full rounded-lg border border-border bg-muted/25 p-1" data-slot="boolean-toggle"
className={cn(
"inline-flex w-full border border-border bg-muted/25",
size === "compact" ? "min-h-8 rounded-md p-0.5" : "min-h-10 rounded-lg p-1",
)}
> >
{[ {[
{ value: true, label: trueLabel }, { value: true, label: trueLabel },
@@ -60,8 +66,12 @@ export function BooleanToggle({
aria-pressed={active} aria-pressed={active}
disabled={disabled} disabled={disabled}
onClick={() => select(option.value)} onClick={() => select(option.value)}
data-slot="boolean-toggle-option"
className={cn( className={cn(
"min-w-fit flex-1 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-60", "min-w-fit flex-1 whitespace-nowrap font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-60",
size === "compact"
? "rounded-[calc(var(--radius)-2px)] px-2 py-1 text-xs leading-4"
: "rounded-md px-3 py-1.5 text-sm",
active active
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/55 hover:text-foreground", : "text-muted-foreground hover:bg-background/55 hover:text-foreground",

View File

@@ -50,11 +50,11 @@ function DialogContent({
return ( return (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:items-center sm:p-8"> <div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-3 sm:items-center sm:p-5">
<DialogPrimitive.Popup <DialogPrimitive.Popup
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"relative my-auto grid w-full max-w-[calc(100%-2rem)] gap-5 rounded-xl border border-border bg-popover p-6 text-sm text-popover-foreground opacity-100 shadow-xl outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-open:slide-in-from-bottom-2 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:slide-out-to-bottom-2", "relative my-auto grid max-h-[calc(100dvh-1.5rem)] w-full max-w-[calc(100%-1rem)] overflow-y-auto gap-3 rounded-lg border border-border bg-popover p-4 text-[13px] text-popover-foreground opacity-100 shadow-xl outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-open:slide-in-from-bottom-2 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:slide-out-to-bottom-2",
className className
)} )}
{...props} {...props}
@@ -66,7 +66,7 @@ function DialogContent({
render={ render={
<Button <Button
variant="ghost" variant="ghost"
className="absolute right-3 top-3" className="absolute right-2.5 top-2.5"
size="icon-sm" size="icon-sm"
/> />
} }
@@ -85,7 +85,58 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="dialog-header" data-slot="dialog-header"
className={cn("flex flex-col gap-2.5", className)} className={cn("flex flex-col gap-1.5", className)}
{...props}
/>
)
}
function canScrollElement(element: HTMLElement, deltaY: number) {
const style = window.getComputedStyle(element);
if (!/(auto|scroll)/.test(style.overflowY)) return false;
if (element.scrollHeight <= element.clientHeight) return false;
if (deltaY > 0) return element.scrollTop < element.scrollHeight - element.clientHeight;
if (deltaY < 0) return element.scrollTop > 0;
return false;
}
function DialogBody({ className, onWheel, ...props }: React.ComponentProps<"div">) {
const bodyRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const body = bodyRef.current;
if (!body) return;
const scrollBody = body;
function handleWheel(event: WheelEvent) {
if (event.defaultPrevented || event.deltaY === 0) return;
let node = event.target instanceof HTMLElement ? event.target : null;
while (node && node !== scrollBody) {
if (canScrollElement(node, event.deltaY)) return;
node = node.parentElement;
}
const maxScrollTop = scrollBody.scrollHeight - scrollBody.clientHeight;
if (maxScrollTop <= 0) return;
const nextScrollTop = Math.min(maxScrollTop, Math.max(0, scrollBody.scrollTop + event.deltaY));
if (nextScrollTop === scrollBody.scrollTop) return;
scrollBody.scrollTop = nextScrollTop;
event.preventDefault();
}
scrollBody.addEventListener("wheel", handleWheel, { passive: false });
return () => scrollBody.removeEventListener("wheel", handleWheel);
}, []);
return (
<div
ref={bodyRef}
data-slot="dialog-body"
className={cn("min-h-0 overflow-y-auto", className)}
onWheel={onWheel}
{...props} {...props}
/> />
) )
@@ -103,7 +154,7 @@ function DialogFooter({
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"-mx-6 -mb-6 flex flex-col-reverse gap-2.5 rounded-b-xl border-t border-border/50 bg-muted/25 p-5 sm:flex-row sm:justify-end", "-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-lg border-t border-border/50 bg-muted/25 p-3 sm:flex-row sm:justify-end",
className className
)} )}
{...props} {...props}
@@ -123,7 +174,7 @@ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn( className={cn(
"font-heading text-lg leading-tight font-semibold tracking-[-0.02em]", "font-heading text-base leading-tight font-semibold tracking-[-0.02em]",
className className
)} )}
{...props} {...props}
@@ -139,7 +190,7 @@ function DialogDescription({
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn( className={cn(
"text-sm leading-6 text-muted-foreground text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", "text-xs leading-5 text-muted-foreground text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className className
)} )}
{...props} {...props}
@@ -149,6 +200,7 @@ function DialogDescription({
export { export {
Dialog, Dialog,
DialogBody,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,

View File

@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"premium-input w-full min-w-0 px-3.5 py-2 text-base outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-semibold file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-4 aria-invalid:ring-destructive/20 md:text-sm", "premium-input h-10 w-full min-w-0 px-3.5 py-2 text-base outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-semibold file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-4 aria-invalid:ring-destructive/20 md:text-sm",
className className
)} )}
{...props} {...props}

View File

@@ -6,7 +6,16 @@ import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root function Select<Value, Multiple extends boolean | undefined = false>({
children,
...props
}: SelectPrimitive.Root.Props<Value, Multiple>) {
return (
<span data-slot="select-root" className="block min-w-0">
<SelectPrimitive.Root {...props}>{children}</SelectPrimitive.Root>
</span>
)
}
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return ( return (
@@ -41,7 +50,8 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"premium-input no-underline flex w-fit items-center justify-between gap-1.5 py-2 pr-2 pl-3 text-sm whitespace-nowrap outline-none select-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-4 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-11 data-[size=sm]:h-9 data-[size=sm]:rounded-xl *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "premium-input no-underline flex w-fit items-center justify-between gap-2 border-border bg-muted/30 py-2 pr-2 pl-3 text-sm font-medium whitespace-nowrap shadow-[var(--shadow-button)] outline-none select-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-4 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[popup-open]:border-primary/35 data-[popup-open]:bg-background hover:border-primary/30 hover:bg-muted/55 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
size === "sm" ? "h-9 rounded-xl" : "h-10",
className className
)} )}
{...props} {...props}
@@ -49,7 +59,7 @@ function SelectTrigger({
{children} {children}
<SelectPrimitive.Icon <SelectPrimitive.Icon
render={ render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" /> <ChevronDownIcon className="pointer-events-none size-5 rounded-md bg-primary/10 p-0.5 text-primary" />
} }
/> />
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>