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 {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
@@ -22,6 +23,13 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
|
||||
@@ -43,6 +51,32 @@ interface AnnouncementFormData {
|
||||
endAt: Date | string | null;
|
||||
}
|
||||
|
||||
const audienceLabels: Record<AnnouncementAudience, string> = {
|
||||
PUBLIC: "公开",
|
||||
USERS: "全部用户",
|
||||
ADMINS: "全部管理员",
|
||||
SPECIFIC_USER: "指定用户",
|
||||
};
|
||||
|
||||
const displayTypeLabels: Record<AnnouncementDisplayType, string> = {
|
||||
INLINE: "普通公告",
|
||||
BIG: "大公告",
|
||||
POPUP: "弹窗公告",
|
||||
};
|
||||
|
||||
function getAudienceLabel(value: unknown) {
|
||||
return audienceLabels[value as AnnouncementAudience] ?? "选择范围";
|
||||
}
|
||||
|
||||
function getDisplayTypeLabel(value: unknown) {
|
||||
return displayTypeLabels[value as AnnouncementDisplayType] ?? "选择展示方式";
|
||||
}
|
||||
|
||||
function getTargetUserLabel(users: AnnouncementOptionUser[], value: unknown) {
|
||||
if (!value) return "不指定";
|
||||
return users.find((user) => user.id === value)?.email ?? "选择用户";
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(value: Date | string | null) {
|
||||
if (!value) {
|
||||
return "";
|
||||
@@ -94,48 +128,55 @@ export function AnnouncementForm({
|
||||
<DialogTrigger render={<Button variant={triggerVariant} size="sm" />}>
|
||||
{triggerLabel ?? "编辑"}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden p-0 sm:max-w-[30rem]">
|
||||
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
|
||||
<DialogTitle>编辑公告</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<DialogBody className="flex-1 px-4 py-3">
|
||||
<form action={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs [&_[data-slot=select-trigger]]:!h-8 [&_[data-slot=select-trigger]]:!min-h-8 [&_[data-slot=select-trigger]]:!px-2.5 [&_[data-slot=select-trigger]]:!text-xs [&_textarea]:!text-xs">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`title-${announcement.id}`}>标题</Label>
|
||||
<Input id={`title-${announcement.id}`} name="title" defaultValue={announcement.title} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`audience-${announcement.id}`}>目标范围</Label>
|
||||
<select
|
||||
id={`audience-${announcement.id}`}
|
||||
<Select
|
||||
name="audience"
|
||||
defaultValue={announcement.audience}
|
||||
onChange={(event) => setAudience(event.target.value as AnnouncementAudience)}
|
||||
className="h-10 w-full px-3 text-sm outline-none"
|
||||
value={audience}
|
||||
onValueChange={(value) => setAudience((value ?? "USERS") as AnnouncementAudience)}
|
||||
>
|
||||
<option value="PUBLIC">公开(登录/注册页可见)</option>
|
||||
<option value="USERS">全部用户</option>
|
||||
<option value="ADMINS">全部管理员</option>
|
||||
<option value="SPECIFIC_USER">指定用户</option>
|
||||
</select>
|
||||
<SelectTrigger id={`audience-${announcement.id}`} className="w-full">
|
||||
<SelectValue>{(value) => getAudienceLabel(value)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="PUBLIC">公开(登录/注册页可见)</SelectItem>
|
||||
<SelectItem value="USERS">全部用户</SelectItem>
|
||||
<SelectItem value="ADMINS">全部管理员</SelectItem>
|
||||
<SelectItem value="SPECIFIC_USER">指定用户</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`displayType-${announcement.id}`}>展示方式</Label>
|
||||
<select
|
||||
id={`displayType-${announcement.id}`}
|
||||
<Select
|
||||
name="displayType"
|
||||
defaultValue={announcement.displayType}
|
||||
className="h-10 w-full px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="INLINE">普通公告</option>
|
||||
<option value="BIG">大公告</option>
|
||||
<option value="POPUP">弹窗公告</option>
|
||||
</select>
|
||||
<SelectTrigger id={`displayType-${announcement.id}`} className="w-full">
|
||||
<SelectValue>{(value) => getDisplayTypeLabel(value)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="INLINE">普通公告</SelectItem>
|
||||
<SelectItem value="BIG">大公告</SelectItem>
|
||||
<SelectItem value="POPUP">弹窗公告</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`dismissible-${announcement.id}`}>允许用户关闭</Label>
|
||||
<BooleanToggle
|
||||
id={`dismissible-${announcement.id}`}
|
||||
@@ -144,29 +185,33 @@ export function AnnouncementForm({
|
||||
trueLabel="允许"
|
||||
falseLabel="不允许"
|
||||
ariaLabel="允许用户关闭"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`targetUserId-${announcement.id}`}>指定用户(可选)</Label>
|
||||
<select
|
||||
id={`targetUserId-${announcement.id}`}
|
||||
<Select
|
||||
name="targetUserId"
|
||||
defaultValue={announcement.targetUserId ?? ""}
|
||||
disabled={audience !== "SPECIFIC_USER"}
|
||||
className="h-10 w-full px-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option value="">不指定</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id={`targetUserId-${announcement.id}`} className="w-full">
|
||||
<SelectValue>{(value) => getTargetUserLabel(users, value)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="">不指定</SelectItem>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`body-${announcement.id}`}>内容</Label>
|
||||
<Textarea
|
||||
id={`body-${announcement.id}`}
|
||||
@@ -177,8 +222,8 @@ export function AnnouncementForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`startAt-${announcement.id}`}>开始时间(可选)</Label>
|
||||
<Input
|
||||
id={`startAt-${announcement.id}`}
|
||||
@@ -187,7 +232,7 @@ export function AnnouncementForm({
|
||||
defaultValue={toDateTimeLocalValue(announcement.startAt)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`endAt-${announcement.id}`}>结束时间(可选)</Label>
|
||||
<Input
|
||||
id={`endAt-${announcement.id}`}
|
||||
@@ -198,7 +243,7 @@ export function AnnouncementForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`sendNotification-${announcement.id}`}>同步发送站内通知</Label>
|
||||
<BooleanToggle
|
||||
id={`sendNotification-${announcement.id}`}
|
||||
@@ -207,13 +252,15 @@ export function AnnouncementForm({
|
||||
trueLabel="发送"
|
||||
falseLabel="不发送"
|
||||
ariaLabel="同步发送站内通知"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PendingSubmitButton className="w-full" pendingLabel="保存中...">
|
||||
<PendingSubmitButton className="h-8 w-full" pendingLabel="保存中...">
|
||||
保存修改
|
||||
</PendingSubmitButton>
|
||||
</form>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -239,50 +286,65 @@ export function CreateAnnouncementButton({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen) {
|
||||
setAudience("USERS");
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger render={<Button />}>发布公告</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden p-0 sm:max-w-[30rem]">
|
||||
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
|
||||
<DialogTitle>发布公告</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<DialogBody className="flex-1 px-4 py-3">
|
||||
<form action={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs [&_[data-slot=select-trigger]]:!h-8 [&_[data-slot=select-trigger]]:!min-h-8 [&_[data-slot=select-trigger]]:!px-2.5 [&_[data-slot=select-trigger]]:!text-xs [&_textarea]:!text-xs">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="create-announcement-title">标题</Label>
|
||||
<Input id="create-announcement-title" name="title" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="create-announcement-audience">目标范围</Label>
|
||||
<select
|
||||
id="create-announcement-audience"
|
||||
<Select
|
||||
name="audience"
|
||||
defaultValue="USERS"
|
||||
onChange={(event) => setAudience(event.target.value as AnnouncementAudience)}
|
||||
className="h-10 w-full px-3 text-sm outline-none"
|
||||
value={audience}
|
||||
onValueChange={(value) => setAudience((value ?? "USERS") as AnnouncementAudience)}
|
||||
>
|
||||
<option value="PUBLIC">公开(登录/注册页可见)</option>
|
||||
<option value="USERS">全部用户</option>
|
||||
<option value="ADMINS">全部管理员</option>
|
||||
<option value="SPECIFIC_USER">指定用户</option>
|
||||
</select>
|
||||
<SelectTrigger id="create-announcement-audience" className="w-full">
|
||||
<SelectValue>{(value) => getAudienceLabel(value)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="PUBLIC">公开(登录/注册页可见)</SelectItem>
|
||||
<SelectItem value="USERS">全部用户</SelectItem>
|
||||
<SelectItem value="ADMINS">全部管理员</SelectItem>
|
||||
<SelectItem value="SPECIFIC_USER">指定用户</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="create-announcement-displayType">展示方式</Label>
|
||||
<select
|
||||
id="create-announcement-displayType"
|
||||
<Select
|
||||
name="displayType"
|
||||
defaultValue="INLINE"
|
||||
className="h-10 w-full px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="INLINE">普通公告</option>
|
||||
<option value="BIG">大公告</option>
|
||||
<option value="POPUP">弹窗公告</option>
|
||||
</select>
|
||||
<SelectTrigger id="create-announcement-displayType" className="w-full">
|
||||
<SelectValue>{(value) => getDisplayTypeLabel(value)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="INLINE">普通公告</SelectItem>
|
||||
<SelectItem value="BIG">大公告</SelectItem>
|
||||
<SelectItem value="POPUP">弹窗公告</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="create-announcement-dismissible">允许用户关闭</Label>
|
||||
<BooleanToggle
|
||||
id="create-announcement-dismissible"
|
||||
@@ -291,45 +353,49 @@ export function CreateAnnouncementButton({
|
||||
trueLabel="允许"
|
||||
falseLabel="不允许"
|
||||
ariaLabel="允许用户关闭"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="create-announcement-targetUserId">指定用户(可选)</Label>
|
||||
<select
|
||||
id="create-announcement-targetUserId"
|
||||
<Select
|
||||
name="targetUserId"
|
||||
defaultValue=""
|
||||
disabled={audience !== "SPECIFIC_USER"}
|
||||
className="h-10 w-full px-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option value="">不指定</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="create-announcement-targetUserId" className="w-full">
|
||||
<SelectValue>{(value) => getTargetUserLabel(users, value)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="">不指定</SelectItem>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="create-announcement-body">内容</Label>
|
||||
<Textarea id="create-announcement-body" name="body" rows={5} required />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="create-announcement-startAt">开始时间(可选)</Label>
|
||||
<Input id="create-announcement-startAt" name="startAt" type="datetime-local" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="create-announcement-endAt">结束时间(可选)</Label>
|
||||
<Input id="create-announcement-endAt" name="endAt" type="datetime-local" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="create-announcement-sendNotification">同步发送站内通知</Label>
|
||||
<BooleanToggle
|
||||
id="create-announcement-sendNotification"
|
||||
@@ -338,13 +404,15 @@ export function CreateAnnouncementButton({
|
||||
trueLabel="发送"
|
||||
falseLabel="不发送"
|
||||
ariaLabel="同步发送站内通知"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PendingSubmitButton className="w-full" pendingLabel="发布中...">
|
||||
<PendingSubmitButton className="h-8 w-full" pendingLabel="发布中...">
|
||||
发布
|
||||
</PendingSubmitButton>
|
||||
</form>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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 { getCommerceData } from "./commerce-data";
|
||||
import { CommerceToggleButton } from "./_components/commerce-actions";
|
||||
import { DiscountTypeSelect } from "./_components/discount-type-select";
|
||||
|
||||
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
|
||||
function formatCouponDiscount(type: string, value: unknown) {
|
||||
const numericValue = Number(value);
|
||||
if (type === "PERCENT_OFF") {
|
||||
return `折扣 ${numericValue}%`;
|
||||
}
|
||||
return `立减 ¥${numericValue.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "商业配置",
|
||||
@@ -51,10 +58,7 @@ export default async function AdminCommercePage() {
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coupon-type">优惠类型</Label>
|
||||
<select id="coupon-type" name="discountType" className={selectClassName} defaultValue="AMOUNT_OFF">
|
||||
<option value="AMOUNT_OFF">立减金额</option>
|
||||
<option value="PERCENT_OFF">折扣百分比</option>
|
||||
</select>
|
||||
<DiscountTypeSelect />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coupon-value">优惠值</Label>
|
||||
@@ -123,7 +127,7 @@ export default async function AdminCommercePage() {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadge tone="warning">
|
||||
{coupon.discountType === "PERCENT_OFF" ? `${Number(coupon.discountValue)}%` : `¥${Number(coupon.discountValue).toFixed(2)}`}
|
||||
{formatCouponDiscount(coupon.discountType, coupon.discountValue)}
|
||||
</StatusBadge>
|
||||
<StatusBadge>{coupon.thresholdAmount == null ? "无门槛" : `满 ¥${Number(coupon.thresholdAmount).toFixed(2)}`}</StatusBadge>
|
||||
<StatusBadge>{coupon.isPublic ? "公开展示" : "仅发放"}</StatusBadge>
|
||||
|
||||
@@ -153,7 +153,7 @@ export function NodeActions({
|
||||
</ConfirmActionButton>
|
||||
|
||||
<Dialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-w-[30rem]">
|
||||
<DialogHeader>
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-primary">
|
||||
<KeyRound className="size-3.5" /> PROBE TOKEN
|
||||
@@ -161,7 +161,7 @@ export function NodeActions({
|
||||
<DialogTitle>探测 Token — {node.name}</DialogTitle>
|
||||
<DialogDescription>关闭后无法再次查看。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-muted-foreground">探测 Token</div>
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -72,8 +73,8 @@ export function NodeForm({
|
||||
{isEdit ? <Pencil className="size-3.5" /> : <Plus className="size-4" />}
|
||||
{triggerLabel || (isEdit ? "编辑" : "添加节点")}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden p-0 sm:max-w-[34rem]">
|
||||
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<Server className="size-4" />
|
||||
</div>
|
||||
@@ -81,10 +82,11 @@ export function NodeForm({
|
||||
<DialogDescription>连接 3x-ui 面板并同步可售入站。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form action={isEdit ? handleEdit : handleCreate} className="space-y-5">
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="mb-3 text-sm font-semibold">基础信息</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<DialogBody className="flex-1 px-4 py-3">
|
||||
<form action={isEdit ? handleEdit : handleCreate} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs">
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-3">
|
||||
<div className="mb-2 text-xs font-semibold">基础信息</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={nameId}>节点名称</Label>
|
||||
<Input id={nameId} name="name" defaultValue={node?.name ?? ""} placeholder="HK-01" />
|
||||
@@ -102,9 +104,9 @@ export function NodeForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="mb-3 text-sm font-semibold">面板凭据</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-3">
|
||||
<div className="mb-2 text-xs font-semibold">面板凭据</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={panelUsernameId}>面板用户名</Label>
|
||||
<Input id={panelUsernameId} name="panelUsername" defaultValue={node?.panelUsername ?? ""} required />
|
||||
@@ -123,15 +125,16 @@ export function NodeForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="-mx-6 -mb-6">
|
||||
<Button type="button" variant="outline" size="lg" onClick={() => setOpen(false)}>
|
||||
<DialogFooter className="-mx-4 -mb-3">
|
||||
<Button type="button" variant="outline" className="h-8" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<PendingSubmitButton size="lg" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
|
||||
<PendingSubmitButton className="h-8" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
|
||||
{isEdit ? "保存并同步" : "创建并同步"}
|
||||
</PendingSubmitButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { InlineHelp } from "@/components/ui/inline-help";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -197,8 +198,8 @@ export function PaymentConfigItem({
|
||||
<Pencil className="size-3.5" />
|
||||
编辑配置
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto bg-card sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden bg-card p-0 sm:max-w-[34rem]">
|
||||
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<ShieldCheck className="size-4" />
|
||||
</div>
|
||||
@@ -206,13 +207,14 @@ export function PaymentConfigItem({
|
||||
<DialogDescription>留空的密钥字段会保持原值。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<DialogBody className="flex-1 px-4 py-3">
|
||||
<form onSubmit={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{fields.map((field) =>
|
||||
field.type === "checkboxes" ? (
|
||||
<div key={field.key} className="sm:col-span-2">
|
||||
<Label>{field.label}</Label>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
{field.options?.map((option) => {
|
||||
const selected = checkboxValues[field.key]?.has(option.value) ?? false;
|
||||
return (
|
||||
@@ -222,7 +224,7 @@ export function PaymentConfigItem({
|
||||
aria-pressed={selected}
|
||||
onClick={() => toggleCheckbox(field.key, option.value)}
|
||||
className={cn(
|
||||
"flex min-h-10 items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20",
|
||||
"flex min-h-8 items-center justify-between gap-2 rounded-lg border px-2.5 py-1.5 text-left text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20",
|
||||
selected
|
||||
? "border-primary/35 bg-primary/10 text-primary"
|
||||
: "border-border bg-muted/20 text-muted-foreground hover:bg-muted/45 hover:text-foreground",
|
||||
@@ -236,7 +238,7 @@ export function PaymentConfigItem({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<Label htmlFor={`${provider}-${field.key}`}>{field.label}</Label>
|
||||
<Input
|
||||
id={`${provider}-${field.key}`}
|
||||
@@ -253,11 +255,11 @@ export function PaymentConfigItem({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-2.5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="whitespace-nowrap text-sm font-semibold">支付通道状态</Label>
|
||||
<Label className="whitespace-nowrap text-xs font-semibold">支付通道状态</Label>
|
||||
<InlineHelp align="start">开关即时生效。</InlineHelp>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,15 +271,16 @@ export function PaymentConfigItem({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={saving}>
|
||||
<DialogFooter className="-mx-4 -mb-3">
|
||||
<Button type="button" variant="outline" className="h-8" onClick={() => setOpen(false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Button type="submit" className="h-8" disabled={saving}>
|
||||
{saving ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
|
||||
@@ -51,10 +51,13 @@ export function PlanBasicsFields({
|
||||
}: PlanBasicsFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("type")}>套餐类型</Label>
|
||||
{isEdit ? (
|
||||
<div id={fieldId("type")} className="premium-input flex h-11 items-center px-3 text-sm font-medium">
|
||||
<div
|
||||
id={fieldId("type")}
|
||||
className="premium-input flex h-8 min-h-8 items-center px-2.5 text-xs font-medium"
|
||||
>
|
||||
{type === "PROXY" ? "代理节点套餐" : "流媒体套餐"}
|
||||
</div>
|
||||
) : (
|
||||
@@ -86,12 +89,12 @@ export function PlanBasicsFields({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("name")}>套餐名称</Label>
|
||||
<Input id={fieldId("name")} name="name" defaultValue={plan?.name ?? ""} required />
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("durationDays")}>有效期(天)</Label>
|
||||
<Input
|
||||
id={fieldId("durationDays")}
|
||||
@@ -101,7 +104,7 @@ export function PlanBasicsFields({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("sortOrder")}>排序</Label>
|
||||
<Input
|
||||
id={fieldId("sortOrder")}
|
||||
@@ -113,7 +116,7 @@ export function PlanBasicsFields({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("description")}>用户说明</Label>
|
||||
<Textarea
|
||||
id={fieldId("description")}
|
||||
@@ -121,6 +124,7 @@ export function PlanBasicsFields({
|
||||
rows={2}
|
||||
defaultValue={plan?.description ?? ""}
|
||||
placeholder="展示给用户"
|
||||
className="min-h-16 px-2.5 py-2 text-xs leading-5"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -135,8 +139,8 @@ export function PlanLimitsFields({
|
||||
plan?: PlanFormValue;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("totalLimit")}>总库存</Label>
|
||||
<Input
|
||||
id={fieldId("totalLimit")}
|
||||
@@ -147,7 +151,7 @@ export function PlanLimitsFields({
|
||||
placeholder="空=不限"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("perUserLimit")}>每用户限购</Label>
|
||||
<Input
|
||||
id={fieldId("perUserLimit")}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
@@ -31,8 +32,8 @@ export type { PlanFormValue, StreamingServiceOption } from "./plan-form-types";
|
||||
|
||||
function FormSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<fieldset className="space-y-4 rounded-lg border border-border bg-muted/20 p-4">
|
||||
<legend className="px-1.5 text-sm font-semibold">{title}</legend>
|
||||
<fieldset className="space-y-3 rounded-lg border border-border bg-muted/20 p-3">
|
||||
<legend className="px-1.5 text-xs font-semibold">{title}</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
@@ -171,13 +172,17 @@ export function PlanForm({
|
||||
>
|
||||
{triggerLabel ?? (isEdit ? "编辑" : "创建套餐")}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-6xl xl:max-w-7xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[min(90dvh,42rem)] flex-col overflow-hidden p-0 sm:max-w-[56rem]">
|
||||
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(event) => void handleSubmit(event)} className="grid gap-4 lg:grid-cols-2">
|
||||
<DialogBody className="flex-1 px-4 py-3">
|
||||
<form
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
className="grid gap-3 text-[12px] leading-5 lg:grid-cols-2 [&_[data-slot=button]]:text-xs [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs [&_[data-slot=select-trigger]]:!h-8 [&_[data-slot=select-trigger]]:!min-h-8 [&_[data-slot=select-trigger]]:!px-2.5 [&_[data-slot=select-trigger]]:!text-xs [&_textarea]:!text-xs"
|
||||
>
|
||||
{/* Left column: basics + resource config */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<FormSection title="基础信息">
|
||||
<PlanBasicsFields
|
||||
fieldId={fieldId}
|
||||
@@ -224,7 +229,7 @@ export function PlanForm({
|
||||
</div>
|
||||
|
||||
{/* Right column: pricing (proxy only) + sales policy + submit */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{type === "PROXY" && (
|
||||
<FormSection title="定价">
|
||||
<ProxyPricingFields
|
||||
@@ -253,11 +258,12 @@ export function PlanForm({
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<Button type="submit" size="lg" className="w-full" disabled={submitting}>
|
||||
<Button type="submit" className="h-8 w-full" disabled={submitting}>
|
||||
{submitting ? "提交中..." : (isEdit ? "保存套餐" : "创建套餐")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -44,14 +44,14 @@ function PolicyToggleRow({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-lg bg-muted/20 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-2 rounded-md bg-muted/20 p-2 sm:min-h-10 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-fit shrink-0 items-center gap-1.5">
|
||||
<p id={labelId} className="whitespace-nowrap text-sm font-medium">
|
||||
<p id={labelId} className="whitespace-nowrap text-xs font-medium">
|
||||
{label}
|
||||
</p>
|
||||
<InlineHelp align="start">{help}</InlineHelp>
|
||||
</div>
|
||||
<div className="w-full sm:w-40 sm:shrink-0">{children}</div>
|
||||
<div className="w-full sm:w-32 sm:shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export function PlanPolicySection({
|
||||
}: PlanPolicySectionProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="form-panel grid gap-3 xl:grid-cols-2">
|
||||
<div className="grid gap-2 rounded-lg border border-border bg-card/75 p-2 shadow-[var(--shadow-soft)] xl:grid-cols-2">
|
||||
<PolicyToggleRow
|
||||
labelId={fieldId("allowRenewal-label")}
|
||||
label="开放续费"
|
||||
@@ -83,6 +83,7 @@ export function PlanPolicySection({
|
||||
trueLabel="开放"
|
||||
falseLabel="关闭"
|
||||
ariaLabel="开放续费"
|
||||
size="compact"
|
||||
/>
|
||||
</PolicyToggleRow>
|
||||
{type === "PROXY" && (
|
||||
@@ -97,15 +98,16 @@ export function PlanPolicySection({
|
||||
trueLabel="开放"
|
||||
falseLabel="关闭"
|
||||
ariaLabel="开放增流量"
|
||||
size="compact"
|
||||
/>
|
||||
</PolicyToggleRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allowRenewal && (
|
||||
<div className="space-y-3 rounded-xl border border-border bg-muted/20 p-4">
|
||||
<div className="space-y-2 rounded-lg border border-border bg-muted/15 p-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("renewalPricingMode")}>续费计价</Label>
|
||||
<input type="hidden" name="renewalPricingMode" value={renewalPricingMode} />
|
||||
<Select value={renewalPricingMode} onValueChange={(value) => setRenewalPricingMode(value as RenewalPricingMode)}>
|
||||
@@ -120,7 +122,7 @@ export function PlanPolicySection({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("renewalPrice")}>
|
||||
{renewalPricingMode === "PER_DAY" ? "续费价格(¥/天)" : "续费价格(¥/周期)"}
|
||||
</Label>
|
||||
@@ -139,7 +141,7 @@ export function PlanPolicySection({
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{renewalPricingMode === "FIXED_DURATION" ? (
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("renewalDurationDays")}>周期天数</Label>
|
||||
<Input
|
||||
id={fieldId("renewalDurationDays")}
|
||||
@@ -153,7 +155,7 @@ export function PlanPolicySection({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("renewalMinDays")}>最小续费天数</Label>
|
||||
<Input
|
||||
id={fieldId("renewalMinDays")}
|
||||
@@ -164,7 +166,7 @@ export function PlanPolicySection({
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("renewalMaxDays")}>最大续费天数</Label>
|
||||
<Input
|
||||
id={fieldId("renewalMaxDays")}
|
||||
@@ -182,9 +184,9 @@ export function PlanPolicySection({
|
||||
)}
|
||||
|
||||
{type === "PROXY" && allowTrafficTopup && (
|
||||
<div className="space-y-3 rounded-xl border border-border bg-muted/20 p-4">
|
||||
<div className="space-y-2 rounded-lg border border-border bg-muted/15 p-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("topupPricingMode")}>增流量计价</Label>
|
||||
<input type="hidden" name="topupPricingMode" value={topupPricingMode} />
|
||||
<Select value={topupPricingMode} onValueChange={(value) => setTopupPricingMode(value as TopupPricingMode)}>
|
||||
@@ -200,7 +202,7 @@ export function PlanPolicySection({
|
||||
</Select>
|
||||
</div>
|
||||
{topupPricingMode === "PER_GB" ? (
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("topupPricePerGb")}>加流量价格(¥/GB)</Label>
|
||||
<Input
|
||||
id={fieldId("topupPricePerGb")}
|
||||
@@ -214,7 +216,7 @@ export function PlanPolicySection({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("topupFixedPrice")}>固定加流量金额(¥)</Label>
|
||||
<Input
|
||||
id={fieldId("topupFixedPrice")}
|
||||
@@ -231,7 +233,7 @@ export function PlanPolicySection({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("minTopupGb")}>最小增流量(GB)</Label>
|
||||
<Input
|
||||
id={fieldId("minTopupGb")}
|
||||
@@ -242,7 +244,7 @@ export function PlanPolicySection({
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("maxTopupGb")}>最大增流量(GB)</Label>
|
||||
<Input
|
||||
id={fieldId("maxTopupGb")}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ProxyNodeFields({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("nodeId")}>节点</Label>
|
||||
<Select
|
||||
value={nodeId}
|
||||
@@ -71,7 +71,7 @@ export function ProxyNodeFields({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label id={fieldId("inboundIds-label")}>可售入站(可多选)</Label>
|
||||
<input type="hidden" name="inboundIds" value={selectedInboundIds.join(",")} />
|
||||
<div className="grid gap-2 sm:grid-cols-2" role="group" aria-labelledby={fieldId("inboundIds-label")}>
|
||||
@@ -82,17 +82,17 @@ export function ProxyNodeFields({
|
||||
key={inbound.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"choice-card text-left px-3 py-2.5 text-sm",
|
||||
"choice-card flex min-h-8 items-center justify-between gap-2 px-2.5 py-1.5 text-left text-xs leading-4",
|
||||
selected
|
||||
? "border-primary/30 bg-primary/10 text-primary"
|
||||
: "hover:bg-muted/45",
|
||||
)}
|
||||
onClick={() => toggleInbound(inbound.id)}
|
||||
>
|
||||
<p className="font-medium">
|
||||
<span className="shrink-0 font-medium leading-4">
|
||||
{inbound.protocol} · {inbound.port}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{inbound.tag}</p>
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[11px] leading-4 text-muted-foreground">{inbound.tag}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -123,7 +123,7 @@ export function ProxyPricingFields({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("pricingMode")}>售卖方式</Label>
|
||||
<Select value={pricingMode} onValueChange={(value) => setPricingMode(value as PlanPricingMode)}>
|
||||
<SelectTrigger id={fieldId("pricingMode")}>
|
||||
@@ -139,8 +139,8 @@ export function ProxyPricingFields({
|
||||
</div>
|
||||
|
||||
{pricingMode === "TRAFFIC_SLIDER" ? (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("pricePerGb")}>价格(¥/GB)</Label>
|
||||
<Input
|
||||
id={fieldId("pricePerGb")}
|
||||
@@ -151,7 +151,7 @@ export function ProxyPricingFields({
|
||||
placeholder="0.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("minTrafficGb")}>最小 GB</Label>
|
||||
<Input
|
||||
id={fieldId("minTrafficGb")}
|
||||
@@ -161,7 +161,7 @@ export function ProxyPricingFields({
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("maxTrafficGb")}>最大 GB</Label>
|
||||
<Input
|
||||
id={fieldId("maxTrafficGb")}
|
||||
@@ -173,8 +173,8 @@ export function ProxyPricingFields({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("fixedTrafficGb")}>固定流量(GB)</Label>
|
||||
<Input
|
||||
id={fieldId("fixedTrafficGb")}
|
||||
@@ -185,7 +185,7 @@ export function ProxyPricingFields({
|
||||
placeholder="200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("fixedPrice")}>固定价格(¥)</Label>
|
||||
<Input
|
||||
id={fieldId("fixedPrice")}
|
||||
@@ -200,7 +200,7 @@ export function ProxyPricingFields({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("totalTrafficGb")}>总流量池(GB)</Label>
|
||||
<Input
|
||||
id={fieldId("totalTrafficGb")}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function StreamingConfigSection({
|
||||
}: StreamingConfigSectionProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("streamingServiceId")}>绑定流媒体服务</Label>
|
||||
<Select
|
||||
value={streamingServiceId}
|
||||
@@ -72,7 +72,7 @@ export function StreamingConfigSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={fieldId("price")}>价格(¥)</Label>
|
||||
<Input
|
||||
id={fieldId("price")}
|
||||
|
||||
@@ -9,7 +9,9 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -50,17 +52,18 @@ export function ServiceForm({
|
||||
<DialogTrigger render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />}>
|
||||
{triggerLabel ?? (isEdit ? "编辑" : "添加服务")}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[min(90dvh,34rem)] flex-col overflow-hidden p-0 sm:max-w-[30rem]">
|
||||
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
|
||||
<DialogTitle>{isEdit ? "编辑流媒体服务" : "添加流媒体服务"}</DialogTitle>
|
||||
<p className="text-sm leading-6 text-muted-foreground">凭据仅后台可见。</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">凭据仅后台可见。</p>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit} className="form-panel space-y-5">
|
||||
<div>
|
||||
<DialogBody className="flex-1 px-4 py-3">
|
||||
<form action={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs [&_textarea]:!text-xs">
|
||||
<div className="space-y-1.5">
|
||||
<Label>名称 (如 Netflix)</Label>
|
||||
<Input name="name" defaultValue={service?.name} required />
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>凭据 (账号密码等)</Label>
|
||||
<Textarea
|
||||
name="credentials"
|
||||
@@ -71,20 +74,24 @@ export function ServiceForm({
|
||||
? "重新输入凭据"
|
||||
: "email: xxx password: xxx"
|
||||
}
|
||||
className="min-h-20 px-2.5 py-2 text-xs leading-5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>最大共享人数</Label>
|
||||
<Input name="maxSlots" type="number" defaultValue={service?.maxSlots ?? 5} required />
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>描述</Label>
|
||||
<Input name="description" defaultValue={service?.description ?? ""} />
|
||||
</div>
|
||||
<PendingSubmitButton size="lg" className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
|
||||
{isEdit ? "保存" : "创建"}
|
||||
</PendingSubmitButton>
|
||||
<DialogFooter className="-mx-4 -mb-3">
|
||||
<PendingSubmitButton className="h-8 w-full sm:w-auto" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
|
||||
{isEdit ? "保存" : "创建"}
|
||||
</PendingSubmitButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,13 @@ import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { InlineHelp } from "@/components/ui/inline-help";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
saveAppSettings,
|
||||
@@ -82,7 +89,6 @@ interface CouponOption {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
|
||||
const sectionClassName = "surface-card scroll-mt-24 space-y-4 rounded-xl p-4";
|
||||
const sectionHeadingClassName = "flex items-center gap-2 text-sm font-semibold";
|
||||
|
||||
@@ -480,18 +486,21 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_12rem_auto] lg:items-end">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manualCleanupTarget">手动清理范围</Label>
|
||||
<select
|
||||
id="manualCleanupTarget"
|
||||
<Select
|
||||
value={cleanupTarget}
|
||||
onChange={(event) => setCleanupTarget(event.target.value as LogCleanupTarget)}
|
||||
className={selectClassName}
|
||||
onValueChange={(value) => setCleanupTarget((value ?? "ALL") as LogCleanupTarget)}
|
||||
>
|
||||
{logCleanupTargetOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="manualCleanupTarget" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{logCleanupTargetOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manualCleanupDays">清理几天前</Label>
|
||||
@@ -510,6 +519,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
description={`清理 ${manualCleanupDays || config.logRetentionDays || 30} 天前的${logCleanupTargetOptions.find((option) => option.value === cleanupTarget)?.label ?? "日志"},无法恢复。`}
|
||||
confirmLabel="开始清理"
|
||||
errorMessage="清理日志失败"
|
||||
size="lg"
|
||||
disabled={saving || hasPendingToggle || cleaningLogs}
|
||||
onConfirm={handleCleanupExpiredLogs}
|
||||
>
|
||||
@@ -805,19 +815,22 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteRewardCouponId">自动发放优惠券</Label>
|
||||
<select
|
||||
id="inviteRewardCouponId"
|
||||
<Select
|
||||
name="inviteRewardCouponId"
|
||||
defaultValue={config.inviteRewardCouponId ?? ""}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="">不发放优惠券</option>
|
||||
{coupons.map((coupon) => (
|
||||
<option key={coupon.id} value={coupon.id}>
|
||||
{coupon.name} · {coupon.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="inviteRewardCouponId" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="">不发放优惠券</SelectItem>
|
||||
{coupons.map((coupon) => (
|
||||
<SelectItem key={coupon.id} value={coupon.id}>
|
||||
{coupon.name} · {coupon.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,44 +1,17 @@
|
||||
import { updateSupportTicketMeta } from "@/actions/admin/support";
|
||||
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { AdminSupportTicketDetail } from "../support-data";
|
||||
import { SupportTicketMetaSelects } from "./support-ticket-meta-selects";
|
||||
|
||||
export function SupportTicketMetaForm({ ticket }: { ticket: AdminSupportTicketDetail }) {
|
||||
return (
|
||||
<form
|
||||
action={updateSupportTicketMeta}
|
||||
className="surface-card flex flex-wrap items-end gap-3 rounded-xl p-4"
|
||||
className="surface-card grid gap-3 rounded-xl p-4 sm:grid-cols-[minmax(10rem,12rem)_minmax(10rem,12rem)_auto] sm:items-end"
|
||||
>
|
||||
<input type="hidden" name="ticketId" value={ticket.id} />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">状态</Label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
defaultValue={ticket.status}
|
||||
className="h-11 px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="OPEN">待处理</option>
|
||||
<option value="USER_REPLIED">用户已回复</option>
|
||||
<option value="ADMIN_REPLIED">管理员已回复</option>
|
||||
<option value="CLOSED">已关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="priority">优先级</Label>
|
||||
<select
|
||||
id="priority"
|
||||
name="priority"
|
||||
defaultValue={ticket.priority}
|
||||
className="h-11 px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="LOW">低</option>
|
||||
<option value="NORMAL">普通</option>
|
||||
<option value="HIGH">高</option>
|
||||
<option value="URGENT">紧急</option>
|
||||
</select>
|
||||
</div>
|
||||
<PendingSubmitButton variant="outline" size="lg" pendingLabel="更新中...">更新状态</PendingSubmitButton>
|
||||
<SupportTicketMetaSelects status={ticket.status} priority={ticket.priority} />
|
||||
<PendingSubmitButton variant="outline" size="lg" className="w-full sm:w-auto" pendingLabel="更新中...">更新状态</PendingSubmitButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -54,11 +61,11 @@ export function UserForm({
|
||||
<DialogTitle>{isEdit ? "编辑用户" : "创建用户"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<Label>邮箱</Label>
|
||||
<Input name="email" type="email" defaultValue={user?.email} required />
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<Label>{isEdit ? "新密码(可留空)" : "密码"}</Label>
|
||||
<Input
|
||||
name="password"
|
||||
@@ -68,20 +75,24 @@ export function UserForm({
|
||||
placeholder={isEdit ? "留空则保持不变" : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<Label>昵称</Label>
|
||||
<Input name="name" defaultValue={user?.name ?? ""} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>角色</Label>
|
||||
<select
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-role">角色</Label>
|
||||
<Select
|
||||
name="role"
|
||||
defaultValue={user?.role ?? "USER"}
|
||||
className="h-10 w-full px-3 text-sm outline-none"
|
||||
>
|
||||
<option value="USER">普通用户</option>
|
||||
<option value="ADMIN">管理员</option>
|
||||
</select>
|
||||
<SelectTrigger id="user-role" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="USER">普通用户</SelectItem>
|
||||
<SelectItem value="ADMIN">管理员</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<PendingSubmitButton className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
|
||||
{isEdit ? "保存" : "创建"}
|
||||
|
||||
Reference in New Issue
Block a user