mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
polish: refine admin ui controls
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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 password: xxx"
|
: "email: xxx 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ? "保存" : "创建"}
|
||||||
|
|||||||
@@ -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" /> 延迟趋势
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}` : "路由详情"}
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user