polish: refine lite admin controls

This commit is contained in:
JetSprow
2026-04-30 21:48:59 +10:00
parent 6ee9cf2857
commit c5592621a4
87 changed files with 326 additions and 272 deletions

View File

@@ -32,7 +32,7 @@ export function AnnouncementsTable({ announcements, users }: AnnouncementsTableP
<DataTableShell
isEmpty={announcements.length === 0}
emptyTitle="暂无公告或消息"
emptyDescription="发布公告后,会显示展示范围、时间窗口和启用状态。"
emptyDescription="发布后显示范围、时间和状态。"
mobileCards={announcements.map((announcement) => (
<article key={announcement.id} className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">

View File

@@ -63,7 +63,7 @@ export function AnnouncementActions({
size="sm"
variant="destructive"
title="删除这条公告?"
description="公告本体和已经同步的站内通知会一起删除,此操作无法恢复。"
description="会删除公告和同步通知,无法恢复。"
confirmLabel="删除公告"
successMessage="公告已删除"
errorMessage="删除失败"

View File

@@ -24,7 +24,7 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
<DataTableShell
isEmpty={logs.length === 0}
emptyTitle="暂无审计日志"
emptyDescription="后台关键操作发生后,会记录在这里。"
emptyDescription="后台关键操作会记录在这里。"
mobileCards={logs.map((log) => (
<article key={log.id} className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">
@@ -36,7 +36,7 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
id={log.id}
target="AUDIT_LOGS"
title="删除这条审计日志?"
description="删除后无法恢复。系统会记录一条新的删除审计,用于保留后台操作痕迹。"
description="会新增一条删除审计记录。"
successMessage="审计日志已删除"
/>
</div>
@@ -99,7 +99,7 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
id={log.id}
target="AUDIT_LOGS"
title="删除这条审计日志?"
description="删除后无法恢复。系统会记录一条新的删除审计,用于保留后台操作痕迹。"
description="会新增一条删除审计记录。"
successMessage="审计日志已删除"
/>
</div>

View File

@@ -26,7 +26,7 @@ export default function BackupsPage() {
<div>
<h3 className="text-lg font-semibold tracking-tight"></h3>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted-foreground">
SQL
SQL
</p>
</div>
</div>

View File

@@ -33,9 +33,7 @@ export function RestoreBackupForm() {
</span>
<div>
<h3 className="text-lg font-semibold tracking-tight"></h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
SQL SQL
</p>
<p className="mt-1 text-sm leading-6 text-muted-foreground"> SQL</p>
</div>
</div>
@@ -46,13 +44,13 @@ export function RestoreBackupForm() {
</div>
<div className="space-y-2">
<Label htmlFor="confirmation"></Label>
<Input id="confirmation" name="confirmation" placeholder="请输入 RESTORE" />
<Input id="confirmation" name="confirmation" placeholder="RESTORE" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="sqlText"> SQL </Label>
<Textarea id="sqlText" name="sqlText" rows={8} placeholder="-- paste sql backup here" />
<Textarea id="sqlText" name="sqlText" rows={8} placeholder="粘贴 SQL 内容" />
</div>
<Button type="submit" size="lg" variant="destructive" disabled={loading} className="w-full sm:w-auto">

View File

@@ -23,7 +23,7 @@ export function RecentSection({ recentOrders, recentUsers }: RecentSectionProps)
{recentOrders.length === 0 ? (
<EmptyState
title="还没有订单"
description="用户创建订单后,这里会显示最新购买和支付状态。"
description="新订单会显示购买和支付状态。"
className="border-0 bg-transparent py-8"
/>
) : (
@@ -62,7 +62,7 @@ export function RecentSection({ recentOrders, recentUsers }: RecentSectionProps)
{recentUsers.length === 0 ? (
<EmptyState
title="还没有新用户"
description="新用户注册后,这里会显示最近加入的账户。"
description="新注册账户会显示在这里。"
className="border-0 bg-transparent py-8"
/>
) : (

View File

@@ -21,7 +21,7 @@ export function InboundsTab({ node }: { node: NodeDetail }) {
return (
<EmptyState
title="暂无已同步入站"
description="请先在 3x-ui 面板创建入站,然后回到节点列表点击测试并同步入站。"
description="在 3x-ui 创建后回到节点列表同步。"
/>
);
}
@@ -29,7 +29,7 @@ export function InboundsTab({ node }: { node: NodeDetail }) {
return (
<div className="space-y-4 pt-4">
<p className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
3x-ui 线
3x-ui
</p>
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{node.inbounds.map((inbound) => (

View File

@@ -77,7 +77,9 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu
))}
</div>
) : (
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground"> 3x-ui </p>
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
</p>
)}
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
@@ -114,7 +116,7 @@ export function NodeCardList({ nodes, siteUrl }: { nodes: NodeServerRow[]; siteU
<div className="p-5">
<EmptyState
title="暂无节点"
description="添加 3x-ui 节点后,可以同步入站并绑定到代理套餐。"
description="添加节点后同步入站并绑定套餐。"
action={<NodeForm triggerLabel="添加节点" />}
/>
</div>

View File

@@ -14,7 +14,7 @@ export function InboundDeleteButton({
variant="ghost"
className="h-7 px-2 text-destructive hover:text-destructive"
title="删除这个线路入口?"
description="这里只会移除本地同步记录,不删除 3x-ui 面板中的入站。请确认没有套餐仍依赖它。"
description="移除本地记录,不删除 3x-ui 入站。"
confirmLabel="删除入口"
successMessage="线路入口已删除"
errorMessage="删除线路入口失败"

View File

@@ -91,7 +91,7 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
size="sm"
variant="outline"
title="撤销这个探测 Token"
description="撤销后,延迟、线路探测和节点日志风控程序将无法继续上报数据。"
description="撤销后探测 Agent 停止上报。"
confirmLabel="撤销 Token"
successMessage="探测 Token 已撤销"
errorMessage="撤销失败"
@@ -105,7 +105,7 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
size="sm"
variant="destructive"
title="删除这个节点?"
description="节点、线路入口和相关探测数据会被清理。请确认没有套餐仍依赖它。"
description="会清理节点、入口和探测数据。"
confirmLabel="删除节点"
successMessage="节点已删除"
errorMessage="删除失败"
@@ -121,7 +121,7 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
<KeyRound className="size-3.5" /> PROBE TOKEN
</div>
<DialogTitle> Token {node.name}</DialogTitle>
<DialogDescription> Token </DialogDescription>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
@@ -166,11 +166,11 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
{!siteUrl && (
<p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-700 dark:text-amber-200">
URL
URL
</p>
)}
<p className="text-xs leading-5 text-muted-foreground">
Agent `/api/agent/latency``/api/agent/trace` 3x-ui/Xray access logAgent 3x-ui
Agent
</p>
</div>
</DialogContent>

View File

@@ -68,9 +68,7 @@ export function NodeForm({
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{isEdit ? "编辑 3x-ui 节点" : "添加 3x-ui 节点"}</DialogTitle>
<DialogDescription>
3x-ui 线 3x-ui
</DialogDescription>
<DialogDescription> 3x-ui </DialogDescription>
</DialogHeader>
<form action={isEdit ? handleEdit : handleCreate} className="form-panel space-y-5">
<div className="grid gap-3 sm:grid-cols-2">
@@ -94,16 +92,14 @@ export function NodeForm({
<Input
name="panelPassword"
type="password"
placeholder={isEdit ? "留空则沿用当前密码" : "请输入面板密码"}
placeholder={isEdit ? "留空不变" : "面板密码"}
required={!isEdit}
autoComplete="new-password"
/>
</div>
</div>
<p className="text-xs leading-5 text-muted-foreground">
线使 Token 3x-ui API
</p>
<p className="text-xs leading-5 text-muted-foreground">使 Token 3x-ui API</p>
<PendingSubmitButton size="lg" className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
{isEdit ? "保存并同步入站" : "创建并同步入站"}
</PendingSubmitButton>

View File

@@ -37,7 +37,7 @@ export function OrdersTable({ orders }: OrdersTableProps) {
<DataTableShell
isEmpty={orders.length === 0}
emptyTitle="暂无订单"
emptyDescription="用户创建订单后,支付审查状态会出现在这里。"
emptyDescription="订单创建后显示支付审查状态。"
toolbar={
<BatchActionBar
id="order-batch-form"

View File

@@ -7,6 +7,7 @@ import { savePaymentConfig, setPaymentConfigEnabled } from "@/actions/admin/paym
import { StatusBadge } from "@/components/shared/status-badge";
import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Button } from "@/components/ui/button";
import { InlineHelp } from "@/components/ui/inline-help";
import {
Dialog,
DialogContent,
@@ -168,7 +169,7 @@ export function PaymentConfigItem({
<h3 className="text-base font-semibold tracking-tight">{providerName}</h3>
{displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>}
</div>
<p className="mt-1 line-clamp-2 text-sm leading-6 text-muted-foreground">{providerDescription}</p>
<p className="mt-1 text-sm text-muted-foreground text-pretty">{providerDescription}</p>
{checkboxSummaries.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{checkboxSummaries.slice(0, 2).map((label) => (
@@ -202,7 +203,7 @@ export function PaymentConfigItem({
<ShieldCheck className="size-4" />
</div>
<DialogTitle>{providerName}</DialogTitle>
<DialogDescription>{providerDescription}</DialogDescription>
<DialogDescription></DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5">
@@ -227,7 +228,7 @@ export function PaymentConfigItem({
: "border-border bg-muted/20 text-muted-foreground hover:bg-muted/45 hover:text-foreground",
)}
>
<span className="truncate">{option.label}</span>
<span className="whitespace-nowrap">{option.label}</span>
{selected && <Check className="size-4 shrink-0" />}
</button>
);
@@ -241,11 +242,11 @@ export function PaymentConfigItem({
id={`${provider}-${field.key}`}
name={field.key}
type={field.secret ? "password" : "text"}
placeholder={field.secret && secretConfigured[field.key] ? "留空保持不变" : field.placeholder}
placeholder={field.secret && secretConfigured[field.key] ? "留空不变" : field.placeholder}
defaultValue={field.secret ? "" : currentConfig?.[field.key] || ""}
/>
{field.secret && secretConfigured[field.key] && (
<p className="text-xs leading-5 text-muted-foreground"></p>
<p className="text-xs leading-5 text-muted-foreground"></p>
)}
</div>
),
@@ -255,8 +256,10 @@ export function PaymentConfigItem({
<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>
<Label className="text-sm font-semibold"></Label>
<p className="mt-1 text-xs leading-5 text-muted-foreground"></p>
<div className="flex items-center gap-1.5">
<Label className="whitespace-nowrap text-sm font-semibold"></Label>
<InlineHelp align="start"></InlineHelp>
</div>
</div>
<div className="flex justify-start sm:justify-end">
<StatusBadge tone={enabled ? "success" : "neutral"}>

View File

@@ -50,7 +50,7 @@ export function PlanActions({
variant="destructive"
size="sm"
title="彻底删除套餐?"
description="关联订阅、本地订单记录和可同步的独占入口会一起处理。此操作无法恢复。"
description="会清理关联订阅和订单,无法恢复。"
confirmLabel="删除套餐"
successMessage="套餐已删除"
errorMessage="删除失败"

View File

@@ -120,7 +120,7 @@ export function PlanBasicsFields({
name="description"
rows={2}
defaultValue={plan?.description ?? ""}
placeholder="适合的使用场景、交付方式与体验边界"
placeholder="展示给用户"
/>
</div>
</>
@@ -144,7 +144,7 @@ export function PlanLimitsFields({
type="number"
min={1}
defaultValue={plan?.totalLimit ?? ""}
placeholder="空=不限"
placeholder="空=不限"
/>
</div>
<div>
@@ -155,7 +155,7 @@ export function PlanLimitsFields({
type="number"
min={1}
defaultValue={plan?.perUserLimit ?? ""}
placeholder="空=不限"
placeholder="空=不限"
/>
</div>
</div>

View File

@@ -171,7 +171,7 @@ export function PlanForm({
>
{triggerLabel ?? (isEdit ? "编辑" : "创建套餐")}
</DialogTrigger>
<DialogContent className="sm:max-w-5xl">
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-6xl xl:max-w-7xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
@@ -232,7 +232,6 @@ export function PlanForm({
plan={plan}
pricingMode={pricingMode}
setPricingMode={setPricingMode}
allowTrafficTopup={allowTrafficTopup}
/>
</FormSection>
)}

View File

@@ -1,7 +1,8 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { InlineHelp } from "@/components/ui/inline-help";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -31,6 +32,30 @@ interface PlanPolicySectionProps {
setTopupPricingMode: Dispatch<SetStateAction<TopupPricingMode>>;
}
function PolicyToggleRow({
labelId,
label,
help,
children,
}: {
labelId: string;
label: string;
help: string;
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 min-w-fit shrink-0 items-center gap-1.5">
<p id={labelId} className="whitespace-nowrap text-sm font-medium">
{label}
</p>
<InlineHelp align="start">{help}</InlineHelp>
</div>
<div className="w-full sm:w-40 sm:shrink-0">{children}</div>
</div>
);
}
export function PlanPolicySection({
fieldId,
type,
@@ -46,38 +71,34 @@ export function PlanPolicySection({
}: PlanPolicySectionProps) {
return (
<>
<div className="form-panel grid gap-4 sm:grid-cols-2">
<div className="flex items-center justify-between gap-4 rounded-lg bg-muted/20 p-3">
<div>
<p id={fieldId("allowRenewal-label")} className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="w-40">
<div className="form-panel grid gap-3 xl:grid-cols-2">
<PolicyToggleRow
labelId={fieldId("allowRenewal-label")}
label="开放续费"
help="用户可在订阅页自助续费。"
>
<BooleanToggle
value={allowRenewal}
onChange={setAllowRenewal}
trueLabel="开放"
falseLabel="关闭"
ariaLabel="开放续费"
/>
</PolicyToggleRow>
{type === "PROXY" && (
<PolicyToggleRow
labelId={fieldId("allowTrafficTopup-label")}
label="开放增流量"
help="用户可在订阅页自助购买额外流量。"
>
<BooleanToggle
value={allowRenewal}
onChange={setAllowRenewal}
value={allowTrafficTopup}
onChange={setAllowTrafficTopup}
trueLabel="开放"
falseLabel="关闭"
ariaLabel="开放续费"
ariaLabel="开放增流量"
/>
</div>
</div>
{type === "PROXY" && (
<div className="flex items-center justify-between gap-4 rounded-lg bg-muted/20 p-3">
<div>
<p id={fieldId("allowTrafficTopup-label")} className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground"> GB</p>
</div>
<div className="w-40">
<BooleanToggle
value={allowTrafficTopup}
onChange={setAllowTrafficTopup}
trueLabel="开放"
falseLabel="关闭"
ariaLabel="开放增流量"
/>
</div>
</div>
</PolicyToggleRow>
)}
</div>
@@ -111,7 +132,7 @@ export function PlanPolicySection({
min={0.01}
required
defaultValue={plan?.renewalPrice ?? ""}
placeholder={renewalPricingMode === "PER_DAY" ? "例如 1" : "例如 29.9"}
placeholder={renewalPricingMode === "PER_DAY" ? "1" : "29.9"}
/>
</div>
</div>
@@ -127,7 +148,7 @@ export function PlanPolicySection({
min={1}
required
defaultValue={plan?.renewalDurationDays ?? plan?.durationDays ?? ""}
placeholder="例如 30"
placeholder="30"
/>
</div>
) : (
@@ -140,7 +161,7 @@ export function PlanPolicySection({
type="number"
min={1}
defaultValue={plan?.renewalMinDays ?? ""}
placeholder="例如 1"
placeholder="1"
/>
</div>
<div>
@@ -151,7 +172,7 @@ export function PlanPolicySection({
type="number"
min={1}
defaultValue={plan?.renewalMaxDays ?? ""}
placeholder="例如 180"
placeholder="180"
/>
</div>
</>
@@ -189,7 +210,7 @@ export function PlanPolicySection({
min={0.01}
required
defaultValue={plan?.topupPricePerGb ?? ""}
placeholder="例如 0.8"
placeholder="0.8"
/>
</div>
) : (
@@ -203,7 +224,7 @@ export function PlanPolicySection({
min={0.01}
required
defaultValue={plan?.topupFixedPrice ?? ""}
placeholder="例如 9.9"
placeholder="9.9"
/>
</div>
)}
@@ -218,7 +239,7 @@ export function PlanPolicySection({
type="number"
min={1}
defaultValue={plan?.minTopupGb ?? ""}
placeholder="默认 1"
placeholder="1"
/>
</div>
<div>
@@ -229,7 +250,7 @@ export function PlanPolicySection({
type="number"
min={1}
defaultValue={plan?.maxTopupGb ?? ""}
placeholder="空=按流量池剩余额度"
placeholder="空=余量"
/>
</div>
</div>

View File

@@ -110,13 +110,11 @@ export function ProxyPricingFields({
plan,
pricingMode,
setPricingMode,
allowTrafficTopup,
}: {
fieldId: FieldId;
plan?: PlanFormValue;
pricingMode: PlanPricingMode;
setPricingMode: Dispatch<SetStateAction<PlanPricingMode>>;
allowTrafficTopup: boolean;
}) {
const pricingModeLabels: Record<string, string> = {
TRAFFIC_SLIDER: "用户自选流量",
@@ -150,7 +148,7 @@ export function ProxyPricingFields({
type="number"
step="0.01"
defaultValue={plan?.pricePerGb ?? ""}
placeholder="例如 0.5"
placeholder="0.5"
/>
</div>
<div>
@@ -160,7 +158,7 @@ export function ProxyPricingFields({
name="minTrafficGb"
type="number"
defaultValue={plan?.minTrafficGb ?? ""}
placeholder="例如 10"
placeholder="10"
/>
</div>
<div>
@@ -170,7 +168,7 @@ export function ProxyPricingFields({
name="maxTrafficGb"
type="number"
defaultValue={plan?.maxTrafficGb ?? ""}
placeholder="例如 1000"
placeholder="1000"
/>
</div>
</div>
@@ -184,7 +182,7 @@ export function ProxyPricingFields({
type="number"
min={1}
defaultValue={plan?.fixedTrafficGb ?? plan?.minTrafficGb ?? ""}
placeholder="例如 200"
placeholder="200"
/>
</div>
<div>
@@ -196,7 +194,7 @@ export function ProxyPricingFields({
step="0.01"
min={0.01}
defaultValue={plan?.fixedPrice ?? ""}
placeholder="例如 29.9"
placeholder="29.9"
/>
</div>
</div>
@@ -210,13 +208,8 @@ export function ProxyPricingFields({
type="number"
min={1}
defaultValue={plan?.totalTrafficGb ?? ""}
placeholder="空=无限流量"
placeholder="空=不限"
/>
{allowTrafficTopup && (
<p className="mt-1.5 text-xs text-muted-foreground">
</p>
)}
</div>
</>
);
@@ -234,7 +227,6 @@ export function ProxyConfigSection(props: {
selectedInboundIds: string[];
setSelectedInboundIds: Dispatch<SetStateAction<string[]>>;
toggleInbound: (inboundId: string) => void;
allowTrafficTopup: boolean;
pricingMode: PlanPricingMode;
setPricingMode: Dispatch<SetStateAction<PlanPricingMode>>;
}) {

View File

@@ -20,7 +20,7 @@ export function ServicesTable({ services }: { services: StreamingServiceRow[] })
<DataTableShell
isEmpty={services.length === 0}
emptyTitle="暂无流媒体服务"
emptyDescription="添加服务后,流媒体套餐才能分配共享槽位。"
emptyDescription="添加后可分配共享槽位。"
toolbar={
<BatchActionBar
id="service-batch-form"
@@ -44,7 +44,7 @@ export function ServicesTable({ services }: { services: StreamingServiceRow[] })
/>
<div className="min-w-0 flex-1">
<p className="break-words text-sm font-semibold">{service.name}</p>
<p className="mt-1 line-clamp-2 break-words text-xs text-muted-foreground">{service.description || "无描述"}</p>
<p className="mt-1 break-words text-xs text-muted-foreground">{service.description || "未填写说明"}</p>
</div>
<ActiveStatusBadge active={service.isActive} />
</div>

View File

@@ -32,7 +32,7 @@ export function ServiceActions({ service }: { service: StreamingService }) {
size="sm"
variant="destructive"
title="删除这个服务?"
description="删除后无法恢复。请确认没有正在使用这个服务的共享名额。"
description="请确认没有名额正在使用。"
confirmLabel="删除服务"
successMessage="服务已删除"
errorMessage="删除失败"

View File

@@ -53,7 +53,7 @@ export function ServiceForm({
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{isEdit ? "编辑流媒体服务" : "添加流媒体服务"}</DialogTitle>
<p className="text-sm leading-6 text-muted-foreground"></p>
<p className="text-sm leading-6 text-muted-foreground"></p>
</DialogHeader>
<form action={handleSubmit} className="form-panel space-y-5">
<div>
@@ -68,7 +68,7 @@ export function ServiceForm({
defaultValue=""
placeholder={
isEdit
? "重新输入最新凭据,不留空"
? "重新输入凭据"
: "email: xxx&#10;password: xxx"
}
/>

View File

@@ -1,12 +1,13 @@
"use client";
import { useState, type FormEvent } from "react";
import { useState, type FormEvent, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { Bell, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react";
import { cleanupExpiredAdminLogs } from "@/actions/admin/logs";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import { BooleanToggle } from "@/components/ui/boolean-toggle";
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 { Textarea } from "@/components/ui/textarea";
@@ -126,6 +127,27 @@ type ToggleValues = Record<BooleanAppSettingField, boolean>;
const booleanSettingLabels = booleanAppSettingLabels;
function LabelWithHelp({
htmlFor,
children,
help,
align = "start",
}: {
htmlFor?: string;
children: ReactNode;
help: ReactNode;
align?: "start" | "center" | "end";
}) {
return (
<div className="flex min-w-0 items-center gap-1.5">
<Label htmlFor={htmlFor} className="whitespace-nowrap">
{children}
</Label>
<InlineHelp align={align}>{help}</InlineHelp>
</div>
);
}
function initialToggleValues(config: AppConfig): ToggleValues {
return {
allowRegistration: config.allowRegistration,
@@ -317,8 +339,10 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<Settings2 className="size-5" />
</span>
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground"></p>
<div className="flex items-center gap-1.5">
<h3 className="text-lg font-semibold"></h3>
<InlineHelp align="start"></InlineHelp>
</div>
</div>
</div>
<nav className="flex gap-2 overflow-x-auto pb-1" aria-label="设置分组">
@@ -349,14 +373,16 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<Input id="siteName" name="siteName" defaultValue={config.siteName} required />
</div>
<div className="space-y-2">
<Label htmlFor="siteUrl"> URL</Label>
<LabelWithHelp htmlFor="siteUrl" help="登录、邮件和支付回跳使用。">
URL
</LabelWithHelp>
<Input id="siteUrl" name="siteUrl" defaultValue={config.siteUrl ?? ""} placeholder="https://panel.example.com" />
<p className="text-xs leading-5 text-muted-foreground"> Agent </p>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="subscriptionUrl"> URL</Label>
<LabelWithHelp htmlFor="subscriptionUrl" help="客户端订阅链接使用。">
URL
</LabelWithHelp>
<Input id="subscriptionUrl" name="subscriptionUrl" defaultValue={config.subscriptionUrl ?? ""} placeholder="https://sub.example.com" />
<p className="text-xs leading-5 text-muted-foreground"> URL 使 sub 便 Cloudflare/WAF 访</p>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="supportContact"></Label>
@@ -371,7 +397,9 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="supportOpenTicketLimit"></Label>
<LabelWithHelp htmlFor="supportOpenTicketLimit" help="同一用户未关闭工单数。">
</LabelWithHelp>
<Input
id="supportOpenTicketLimit"
name="supportOpenTicketLimit"
@@ -381,9 +409,6 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
step={1}
defaultValue={config.supportOpenTicketLimit}
/>
<p className="text-xs leading-5 text-muted-foreground">
2
</p>
</div>
</div>
</section>
@@ -406,7 +431,9 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
{renderImmediateToggle("trafficSyncEnabled", { id: "trafficSyncEnabled" })}
</div>
<div className="space-y-2">
<Label htmlFor="trafficSyncIntervalSeconds"></Label>
<LabelWithHelp htmlFor="trafficSyncIntervalSeconds" help="最低 10 秒。">
</LabelWithHelp>
<Input
id="trafficSyncIntervalSeconds"
name="trafficSyncIntervalSeconds"
@@ -416,7 +443,6 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
defaultValue={config.trafficSyncIntervalSeconds}
placeholder="60"
/>
<p className="text-xs leading-5 text-muted-foreground"> 60 10 </p>
</div>
</div>
</section>
@@ -424,10 +450,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<section id="settings-logs" className={sectionClass("logs")}>
<div className={sectionHeadingClassName}>
<Trash2 className="size-4 text-primary" />
<InlineHelp align="start"></InlineHelp>
</div>
<p className="text-xs leading-5 text-muted-foreground">
30
</p>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="logCleanupEnabled"></Label>
@@ -483,7 +507,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div>
<ConfirmActionButton
title="清理过期日志?"
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="开始清理"
errorMessage="清理日志失败"
disabled={saving || hasPendingToggle || cleaningLogs}
@@ -493,9 +517,10 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
{cleaningLogs ? "清理中..." : "清理过期日志"}
</ConfirmActionButton>
</div>
<p className="mt-3 text-xs leading-5 text-muted-foreground">
</p>
<div className="mt-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<InlineHelp align="start"></InlineHelp>
</div>
</div>
</section>
@@ -505,18 +530,16 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="networkRecommendationsEnabled"></Label>
<LabelWithHelp htmlFor="networkRecommendationsEnabled" help="商城显示低延迟推荐。">
</LabelWithHelp>
{renderImmediateToggle("networkRecommendationsEnabled", { id: "networkRecommendationsEnabled" })}
<p className="text-xs leading-5 text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="networkInsightsEnabled">线</Label>
<LabelWithHelp htmlFor="networkInsightsEnabled" help="套餐详情显示延迟与路径。">
线
</LabelWithHelp>
{renderImmediateToggle("networkInsightsEnabled", { id: "networkInsightsEnabled" })}
<p className="text-xs leading-5 text-muted-foreground">
访线
</p>
</div>
</div>
</section>
@@ -524,10 +547,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<section id="settings-risk" className={sectionClass("risk")}>
<div className={sectionHeadingClassName}>
<ShieldAlert className="size-4 text-primary" /> 访
<InlineHelp align="start"></InlineHelp>
</div>
<p className="text-xs leading-5 text-muted-foreground">
访{toggleValues.subscriptionRiskEnabled ? "已开启" : "已关闭"}
</p>
<div id="subscription-risk-settings" className="space-y-4">
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2">
@@ -668,9 +689,10 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<Input id="nodeAccessUniqueTargetSuspend" name="nodeAccessUniqueTargetSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetSuspend} />
</div>
</div>
<p className="text-xs leading-5 text-muted-foreground">
24 4 5 2 /3 /2 3 IP 180 / 60 / Agent XRAY_ACCESS_LOG_PATH Agent
</p>
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<InlineHelp align="start"></InlineHelp>
</div>
</div>
</section>
@@ -698,14 +720,15 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
})}
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="emailVerificationRequired"></Label>
<LabelWithHelp htmlFor="emailVerificationRequired" help="验证后创建账户。">
</LabelWithHelp>
{renderImmediateToggle("emailVerificationRequired", {
id: "emailVerificationRequired",
trueLabel: "开启验证",
falseLabel: "关闭",
ariaLabel: "注册邮箱验证",
})}
<p className="text-xs leading-5 text-muted-foreground"></p>
</div>
</div>
</section>
@@ -713,10 +736,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<section id="settings-email" className={sectionClass("email")}>
<div className={sectionHeadingClassName}>
<Mail className="size-4 text-primary" /> SMTP
<InlineHelp align="start"></InlineHelp>
</div>
<p className="text-xs leading-5 text-muted-foreground">
SMTP
</p>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="smtpEnabled"></Label>
@@ -745,7 +766,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div>
<div className="space-y-2">
<Label htmlFor="smtpPassword">SMTP </Label>
<Input id="smtpPassword" name="smtpPassword" type="password" placeholder="留空保持不变" autoComplete="new-password" />
<Input id="smtpPassword" name="smtpPassword" type="password" placeholder="留空不变" autoComplete="new-password" />
</div>
<div className="space-y-2">
<Label htmlFor="smtpFromName"></Label>
@@ -771,6 +792,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<section id="settings-invite" className={sectionClass("invite")}>
<div className={sectionHeadingClassName}>
<Gift className="size-4 text-primary" />
<InlineHelp align="start"></InlineHelp>
</div>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2">
@@ -798,18 +820,13 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</select>
</div>
</div>
<p className="text-xs leading-5 text-muted-foreground">
</p>
</section>
<section id="settings-turnstile" className={sectionClass("turnstile")}>
<div className={sectionHeadingClassName}>
<ShieldAlert className="size-4 text-primary" /> Cloudflare Turnstile
<InlineHelp align="start"></InlineHelp>
</div>
<p className="text-xs leading-5 text-muted-foreground">
</p>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="turnstileSiteKey">Site Key</Label>
@@ -821,11 +838,14 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
id="turnstileSecretKey"
name="turnstileSecretKey"
type="password"
placeholder={config.turnstileSecretConfigured ? "留空保持不变" : "0x4AAAAAAA..."}
placeholder={config.turnstileSecretConfigured ? "留空不变" : "0x4AAAAAAA..."}
autoComplete="new-password"
/>
{config.turnstileSecretConfigured && (
<p className="text-xs leading-5 text-muted-foreground">Secret Key Site Key Turnstile</p>
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<InlineHelp align="start"></InlineHelp>
</div>
)}
</div>
</div>

View File

@@ -111,7 +111,7 @@ function AnalysisLogDetails({ summary }: { summary: SubscriptionRiskGeoSummary }
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 text-sm font-medium [&::-webkit-details-marker]:hidden">
<span className="flex min-w-0 items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" />
<span className="truncate"></span>
<span className="whitespace-nowrap"></span>
</span>
<span className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{logs.length}
@@ -137,7 +137,7 @@ function AnalysisLogDetails({ summary }: { summary: SubscriptionRiskGeoSummary }
id={log.id}
target="SUBSCRIPTION_ACCESS_LOGS"
title="删除这条风控访问日志?"
description="删除后无法恢复,只会移除这条访问或节点连接证据,不会删除用户订阅或风控事件。"
description="删除这条证据,不影响用户订阅。"
successMessage="风控访问日志已删除"
/>
</div>
@@ -176,7 +176,7 @@ export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionR
</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold"></h3>
<p className="truncate text-xs text-muted-foreground"> IP </p>
<p className="whitespace-nowrap text-xs text-muted-foreground"> IP </p>
</div>
</div>
<StatusBadge tone={summary.uniqueCountryCount > 1 ? "danger" : summary.uniqueRegionCount > 1 ? "warning" : "info"}>

View File

@@ -260,8 +260,8 @@ function RiskEventCard({ event }: { event: SubscriptionRiskEventRow }) {
title="删除这条风控事件?"
description={
event.userRestrictionActive
? "删除后无法恢复。此事件当前仍有用户端限制标记,请先确认是否需要在处理动作里解除限制。"
: "删除后无法恢复,只会移除这条风控事件,不会删除用户、订阅或访问日志。"
? "当前仍有限制标记,请先确认是否解除。"
: "删除事件,不影响用户、订阅或日志。"
}
successMessage="风控事件已删除"
className="w-full justify-center text-destructive hover:text-destructive"
@@ -280,7 +280,7 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
return (
<EmptyState
title="暂无订阅风控事件"
description="订阅链接或节点真实连接出现跨城市、跨省份或跨国家异常后,会在这里进入人工跟进队列。"
description="跨地区访问异常会进入人工队列。"
/>
);
}

View File

@@ -22,7 +22,7 @@ export default async function AdminSubscriptionRiskPage({
<PageHeader
eyebrow="商品与订单"
title="订阅风控"
description="订阅链接或节点真实连接出现跨城市、跨省份访问异常后,会进入这里供管理员确认、备注、恢复或继续处置。"
description="集中处理跨城市、跨省份访问异常。"
/>
<AdminFilterBar

View File

@@ -121,7 +121,7 @@ export function SubscriptionAccessRiskSection({
</span>
<div>
<h3 className="text-lg font-semibold tracking-[-0.02em]">访</h3>
<p className="mt-0.5 text-sm text-muted-foreground"> IP</p>
<p className="mt-0.5 text-sm text-muted-foreground"> IP</p>
</div>
</div>
<Link href="/admin/subscription-risk" className={buttonVariants({ variant: "outline", size: "sm" })}>
@@ -203,7 +203,7 @@ export function SubscriptionAccessRiskSection({
<DataTableShell
isEmpty={accessLogs.length === 0}
emptyTitle="暂无订阅访问记录"
emptyDescription="用户客户端拉取订阅后,这里会显示最近访问 IP 与地区。"
emptyDescription="客户端拉取订阅后显示 IP 与地区。"
>
<DataTable aria-label="订阅访问记录" className="min-w-[980px]">
<DataTableHead>

View File

@@ -66,7 +66,7 @@ export function SubscriptionsTable({
<DataTableShell
isEmpty={subscriptions.length === 0}
emptyTitle="暂无订阅记录"
emptyDescription="用户完成购买开通后订阅会出现在这里。"
emptyDescription="购买开通后订阅会显示在这里。"
toolbar={
<BatchActionBar
id="subscription-batch-form"

View File

@@ -75,7 +75,7 @@ export function AdminSubscriptionActions({
size="sm"
variant="destructive"
title="彻底删除这个订阅?"
description="会同步删除远端客户端,并清理本地记录与相关订单。此操作无法恢复。"
description="会删除远端客户端和本地记录,无法恢复。"
confirmLabel="删除订阅"
successMessage="订阅已删除"
errorMessage="删除失败"

View File

@@ -20,12 +20,12 @@ export function AdminSupportReplyForm({ ticketId }: { ticketId: string }) {
</span>
<div>
<h3 className="font-heading text-lg font-semibold tracking-tight"></h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground"></p>
<p className="mt-1 text-sm leading-6 text-muted-foreground"></p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="body"></Label>
<Textarea id="body" name="body" rows={4} placeholder="输入给用户的回复" required />
<Textarea id="body" name="body" rows={4} placeholder="回复内容" required />
</div>
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-4">
<Label htmlFor="admin-reply-attachments" className="inline-flex items-center gap-2">
@@ -38,9 +38,7 @@ export function AdminSupportReplyForm({ ticketId }: { ticketId: string }) {
multiple
accept={SUPPORT_ATTACHMENT_ACCEPT}
/>
<p className="field-note">
JPGPNGWEBPGIFAVIF 3 3MB
</p>
<p className="field-note"> 3 3MB</p>
</div>
<PendingSubmitButton size="lg" className="w-full sm:w-auto" pendingLabel="发送中..."></PendingSubmitButton>
</form>

View File

@@ -28,7 +28,7 @@ export function AdminSupportTable({ tickets }: AdminSupportTableProps) {
<DataTableShell
isEmpty={tickets.length === 0}
emptyTitle="暂无工单"
emptyDescription="用户提交售后问题后,会显示在这里。"
emptyDescription="用户提交后工单会显示在这里。"
mobileCards={tickets.map((ticket) => (
<article key={ticket.id} className="space-y-3 p-4">
<div className="min-w-0">

View File

@@ -11,7 +11,7 @@ export function TaskLaunchPanel() {
</span>
<div>
<p className="font-semibold"></p>
<p className="mt-1 text-xs leading-5 text-muted-foreground"></p>
<p className="mt-1 text-xs leading-5 text-muted-foreground"></p>
</div>
<PendingSubmitButton size="sm" variant="outline" className="mt-auto w-full" pendingLabel="派发中..."></PendingSubmitButton>
</form>

View File

@@ -25,7 +25,7 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) {
<DataTableShell
isEmpty={tasks.length === 0}
emptyTitle="暂无任务记录"
emptyDescription="手动或定时任务执行后,会显示运行状态与错误信息。"
emptyDescription="任务执行后显示状态与错误。"
toolbar={
<BatchActionBar
id="task-batch-form"
@@ -79,7 +79,7 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) {
id={task.id}
target="TASK_RUNS"
title="删除这条任务记录?"
description="删除后无法恢复,只会移除任务执行记录,不撤销任务已经产生的业务结果。"
description="删除记录,不撤销业务结果。"
successMessage="任务记录已删除"
/>
</div>
@@ -142,7 +142,7 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) {
id={task.id}
target="TASK_RUNS"
title="删除这条任务记录?"
description="删除后无法恢复,只会移除任务执行记录,不撤销任务已经产生的业务结果。"
description="删除记录,不撤销业务结果。"
successMessage="任务记录已删除"
/>
</div>

View File

@@ -40,7 +40,7 @@ export function TrafficClientsTable({ clients }: TrafficClientsTableProps) {
<DataTableShell
isEmpty={visibleClients.length === 0}
emptyTitle="暂无流量数据"
emptyDescription="客户端绑定订阅并同步流量后会显示在这里。"
emptyDescription="同步流量后客户端会显示在这里。"
mobileCards={visibleClients.map((client) => {
const subscription = client.subscription!;
const used = Number(subscription.trafficUsed);

View File

@@ -24,7 +24,7 @@ export function UsersTable({ users }: UsersTableProps) {
<DataTableShell
isEmpty={users.length === 0}
emptyTitle="暂无用户"
emptyDescription="创建用户或等待新用户注册后会显示在这里。"
emptyDescription="创建注册后用户会显示在这里。"
toolbar={
<BatchActionBar
id="user-batch-form"

View File

@@ -63,7 +63,7 @@ export function UserActions({ user }: { user: User }) {
size="sm"
variant="destructive"
title="强制删除这个用户?"
description="将同步删除该用户在节点面板中的客户端,并永久清理名下订单、订阅、工单、通知、访问日志等数据。此操作不可恢复。"
description="会清理节点客户端、订单、订阅、工单和日志,无法恢复。"
confirmLabel="强制删除"
successMessage="用户已删除"
errorMessage="删除失败"