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

@@ -389,9 +389,6 @@ export async function updatePlan(id: string, formData: FormData) {
const nodeId = data.nodeId ?? existing.nodeId; const nodeId = data.nodeId ?? existing.nodeId;
if (!nodeId) throw new Error("代理套餐必须选择节点"); if (!nodeId) throw new Error("代理套餐必须选择节点");
if (data.totalTrafficGb == null || data.totalTrafficGb <= 0) {
throw new Error("代理套餐必须填写总流量池,且大于 0");
}
const inboundIds = parseInboundIds(data.inboundIds, data.inboundId); const inboundIds = parseInboundIds(data.inboundIds, data.inboundId);
if (inboundIds.length === 0) { if (inboundIds.length === 0) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,9 +33,7 @@ export function RestoreBackupForm() {
</span> </span>
<div> <div>
<h3 className="text-lg font-semibold tracking-tight"></h3> <h3 className="text-lg font-semibold tracking-tight"></h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground"> <p className="mt-1 text-sm leading-6 text-muted-foreground"> SQL</p>
SQL SQL
</p>
</div> </div>
</div> </div>
@@ -46,13 +44,13 @@ export function RestoreBackupForm() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirmation"></Label> <Label htmlFor="confirmation"></Label>
<Input id="confirmation" name="confirmation" placeholder="请输入 RESTORE" /> <Input id="confirmation" name="confirmation" placeholder="RESTORE" />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="sqlText"> SQL </Label> <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> </div>
<Button type="submit" size="lg" variant="destructive" disabled={loading} className="w-full sm:w-auto"> <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 ? ( {recentOrders.length === 0 ? (
<EmptyState <EmptyState
title="还没有订单" title="还没有订单"
description="用户创建订单后,这里会显示最新购买和支付状态。" description="新订单会显示购买和支付状态。"
className="border-0 bg-transparent py-8" className="border-0 bg-transparent py-8"
/> />
) : ( ) : (
@@ -62,7 +62,7 @@ export function RecentSection({ recentOrders, recentUsers }: RecentSectionProps)
{recentUsers.length === 0 ? ( {recentUsers.length === 0 ? (
<EmptyState <EmptyState
title="还没有新用户" title="还没有新用户"
description="新用户注册后,这里会显示最近加入的账户。" description="新注册账户会显示在这里。"
className="border-0 bg-transparent py-8" className="border-0 bg-transparent py-8"
/> />
) : ( ) : (

View File

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

View File

@@ -77,7 +77,9 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu
))} ))}
</div> </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"> <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"> <div className="p-5">
<EmptyState <EmptyState
title="暂无节点" title="暂无节点"
description="添加 3x-ui 节点后,可以同步入站并绑定到代理套餐。" description="添加节点后同步入站并绑定套餐。"
action={<NodeForm triggerLabel="添加节点" />} action={<NodeForm triggerLabel="添加节点" />}
/> />
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ export function OrdersTable({ orders }: OrdersTableProps) {
<DataTableShell <DataTableShell
isEmpty={orders.length === 0} isEmpty={orders.length === 0}
emptyTitle="暂无订单" emptyTitle="暂无订单"
emptyDescription="用户创建订单后,支付审查状态会出现在这里。" emptyDescription="订单创建后显示支付审查状态。"
toolbar={ toolbar={
<BatchActionBar <BatchActionBar
id="order-batch-form" 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 { StatusBadge } from "@/components/shared/status-badge";
import { BooleanToggle } from "@/components/ui/boolean-toggle"; import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InlineHelp } from "@/components/ui/inline-help";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -168,7 +169,7 @@ export function PaymentConfigItem({
<h3 className="text-base font-semibold tracking-tight">{providerName}</h3> <h3 className="text-base font-semibold tracking-tight">{providerName}</h3>
{displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>} {displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>}
</div> </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 && ( {checkboxSummaries.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{checkboxSummaries.slice(0, 2).map((label) => ( {checkboxSummaries.slice(0, 2).map((label) => (
@@ -202,7 +203,7 @@ export function PaymentConfigItem({
<ShieldCheck className="size-4" /> <ShieldCheck className="size-4" />
</div> </div>
<DialogTitle>{providerName}</DialogTitle> <DialogTitle>{providerName}</DialogTitle>
<DialogDescription>{providerDescription}</DialogDescription> <DialogDescription></DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5"> <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", : "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" />} {selected && <Check className="size-4 shrink-0" />}
</button> </button>
); );
@@ -241,11 +242,11 @@ export function PaymentConfigItem({
id={`${provider}-${field.key}`} id={`${provider}-${field.key}`}
name={field.key} name={field.key}
type={field.secret ? "password" : "text"} 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] || ""} defaultValue={field.secret ? "" : currentConfig?.[field.key] || ""}
/> />
{field.secret && secretConfigured[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> </div>
), ),
@@ -255,8 +256,10 @@ export function PaymentConfigItem({
<div className="rounded-lg border border-border bg-muted/20 p-3"> <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="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<Label className="text-sm font-semibold"></Label> <div className="flex items-center gap-1.5">
<p className="mt-1 text-xs leading-5 text-muted-foreground"></p> <Label className="whitespace-nowrap text-sm font-semibold"></Label>
<InlineHelp align="start"></InlineHelp>
</div>
</div> </div>
<div className="flex justify-start sm:justify-end"> <div className="flex justify-start sm:justify-end">
<StatusBadge tone={enabled ? "success" : "neutral"}> <StatusBadge tone={enabled ? "success" : "neutral"}>

View File

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

View File

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

View File

@@ -171,7 +171,7 @@ export function PlanForm({
> >
{triggerLabel ?? (isEdit ? "编辑" : "创建套餐")} {triggerLabel ?? (isEdit ? "编辑" : "创建套餐")}
</DialogTrigger> </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> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -232,7 +232,6 @@ export function PlanForm({
plan={plan} plan={plan}
pricingMode={pricingMode} pricingMode={pricingMode}
setPricingMode={setPricingMode} setPricingMode={setPricingMode}
allowTrafficTopup={allowTrafficTopup}
/> />
</FormSection> </FormSection>
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
"use client"; "use client";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent, type ReactNode } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Bell, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react"; import { Bell, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react";
import { cleanupExpiredAdminLogs } from "@/actions/admin/logs"; import { cleanupExpiredAdminLogs } from "@/actions/admin/logs";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import { BooleanToggle } from "@/components/ui/boolean-toggle"; import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { InlineHelp } from "@/components/ui/inline-help";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -126,6 +127,27 @@ type ToggleValues = Record<BooleanAppSettingField, boolean>;
const booleanSettingLabels = booleanAppSettingLabels; 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 { function initialToggleValues(config: AppConfig): ToggleValues {
return { return {
allowRegistration: config.allowRegistration, allowRegistration: config.allowRegistration,
@@ -317,8 +339,10 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<Settings2 className="size-5" /> <Settings2 className="size-5" />
</span> </span>
<div> <div>
<h3 className="text-lg font-semibold"></h3> <div className="flex items-center gap-1.5">
<p className="mt-1 text-sm leading-6 text-muted-foreground"></p> <h3 className="text-lg font-semibold"></h3>
<InlineHelp align="start"></InlineHelp>
</div>
</div> </div>
</div> </div>
<nav className="flex gap-2 overflow-x-auto pb-1" aria-label="设置分组"> <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 /> <Input id="siteName" name="siteName" defaultValue={config.siteName} required />
</div> </div>
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2 md:col-span-2"> <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" /> <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>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<Label htmlFor="supportContact"></Label> <Label htmlFor="supportContact"></Label>
@@ -371,7 +397,9 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="supportOpenTicketLimit"></Label> <LabelWithHelp htmlFor="supportOpenTicketLimit" help="同一用户未关闭工单数。">
</LabelWithHelp>
<Input <Input
id="supportOpenTicketLimit" id="supportOpenTicketLimit"
name="supportOpenTicketLimit" name="supportOpenTicketLimit"
@@ -381,9 +409,6 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
step={1} step={1}
defaultValue={config.supportOpenTicketLimit} defaultValue={config.supportOpenTicketLimit}
/> />
<p className="text-xs leading-5 text-muted-foreground">
2
</p>
</div> </div>
</div> </div>
</section> </section>
@@ -406,7 +431,9 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
{renderImmediateToggle("trafficSyncEnabled", { id: "trafficSyncEnabled" })} {renderImmediateToggle("trafficSyncEnabled", { id: "trafficSyncEnabled" })}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="trafficSyncIntervalSeconds"></Label> <LabelWithHelp htmlFor="trafficSyncIntervalSeconds" help="最低 10 秒。">
</LabelWithHelp>
<Input <Input
id="trafficSyncIntervalSeconds" id="trafficSyncIntervalSeconds"
name="trafficSyncIntervalSeconds" name="trafficSyncIntervalSeconds"
@@ -416,7 +443,6 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
defaultValue={config.trafficSyncIntervalSeconds} defaultValue={config.trafficSyncIntervalSeconds}
placeholder="60" placeholder="60"
/> />
<p className="text-xs leading-5 text-muted-foreground"> 60 10 </p>
</div> </div>
</div> </div>
</section> </section>
@@ -424,10 +450,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<section id="settings-logs" className={sectionClass("logs")}> <section id="settings-logs" className={sectionClass("logs")}>
<div className={sectionHeadingClassName}> <div className={sectionHeadingClassName}>
<Trash2 className="size-4 text-primary" /> <Trash2 className="size-4 text-primary" />
<InlineHelp align="start"></InlineHelp>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground">
30
</p>
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="logCleanupEnabled"></Label> <Label htmlFor="logCleanupEnabled"></Label>
@@ -483,7 +507,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
<ConfirmActionButton <ConfirmActionButton
title="清理过期日志?" 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="开始清理" confirmLabel="开始清理"
errorMessage="清理日志失败" errorMessage="清理日志失败"
disabled={saving || hasPendingToggle || cleaningLogs} disabled={saving || hasPendingToggle || cleaningLogs}
@@ -493,9 +517,10 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
{cleaningLogs ? "清理中..." : "清理过期日志"} {cleaningLogs ? "清理中..." : "清理过期日志"}
</ConfirmActionButton> </ConfirmActionButton>
</div> </div>
<p className="mt-3 text-xs leading-5 text-muted-foreground"> <div className="mt-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
</p> <InlineHelp align="start"></InlineHelp>
</div>
</div> </div>
</section> </section>
@@ -505,18 +530,16 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="networkRecommendationsEnabled"></Label> <LabelWithHelp htmlFor="networkRecommendationsEnabled" help="商城显示低延迟推荐。">
</LabelWithHelp>
{renderImmediateToggle("networkRecommendationsEnabled", { id: "networkRecommendationsEnabled" })} {renderImmediateToggle("networkRecommendationsEnabled", { id: "networkRecommendationsEnabled" })}
<p className="text-xs leading-5 text-muted-foreground">
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="networkInsightsEnabled">线</Label> <LabelWithHelp htmlFor="networkInsightsEnabled" help="套餐详情显示延迟与路径。">
线
</LabelWithHelp>
{renderImmediateToggle("networkInsightsEnabled", { id: "networkInsightsEnabled" })} {renderImmediateToggle("networkInsightsEnabled", { id: "networkInsightsEnabled" })}
<p className="text-xs leading-5 text-muted-foreground">
访线
</p>
</div> </div>
</div> </div>
</section> </section>
@@ -524,10 +547,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<section id="settings-risk" className={sectionClass("risk")}> <section id="settings-risk" className={sectionClass("risk")}>
<div className={sectionHeadingClassName}> <div className={sectionHeadingClassName}>
<ShieldAlert className="size-4 text-primary" /> 访 <ShieldAlert className="size-4 text-primary" /> 访
<InlineHelp align="start"></InlineHelp>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground">
访{toggleValues.subscriptionRiskEnabled ? "已开启" : "已关闭"}
</p>
<div id="subscription-risk-settings" className="space-y-4"> <div id="subscription-risk-settings" className="space-y-4">
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2"> <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} /> <Input id="nodeAccessUniqueTargetSuspend" name="nodeAccessUniqueTargetSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetSuspend} />
</div> </div>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
24 4 5 2 /3 /2 3 IP 180 / 60 / Agent XRAY_ACCESS_LOG_PATH Agent
</p> <InlineHelp align="start"></InlineHelp>
</div>
</div> </div>
</section> </section>
@@ -698,14 +720,15 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
})} })}
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<Label htmlFor="emailVerificationRequired"></Label> <LabelWithHelp htmlFor="emailVerificationRequired" help="验证后创建账户。">
</LabelWithHelp>
{renderImmediateToggle("emailVerificationRequired", { {renderImmediateToggle("emailVerificationRequired", {
id: "emailVerificationRequired", id: "emailVerificationRequired",
trueLabel: "开启验证", trueLabel: "开启验证",
falseLabel: "关闭", falseLabel: "关闭",
ariaLabel: "注册邮箱验证", ariaLabel: "注册邮箱验证",
})} })}
<p className="text-xs leading-5 text-muted-foreground"></p>
</div> </div>
</div> </div>
</section> </section>
@@ -713,10 +736,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<section id="settings-email" className={sectionClass("email")}> <section id="settings-email" className={sectionClass("email")}>
<div className={sectionHeadingClassName}> <div className={sectionHeadingClassName}>
<Mail className="size-4 text-primary" /> SMTP <Mail className="size-4 text-primary" /> SMTP
<InlineHelp align="start"></InlineHelp>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground">
SMTP
</p>
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="smtpEnabled"></Label> <Label htmlFor="smtpEnabled"></Label>
@@ -745,7 +766,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="smtpPassword">SMTP </Label> <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>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="smtpFromName"></Label> <Label htmlFor="smtpFromName"></Label>
@@ -771,6 +792,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<section id="settings-invite" className={sectionClass("invite")}> <section id="settings-invite" className={sectionClass("invite")}>
<div className={sectionHeadingClassName}> <div className={sectionHeadingClassName}>
<Gift className="size-4 text-primary" /> <Gift className="size-4 text-primary" />
<InlineHelp align="start"></InlineHelp>
</div> </div>
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
@@ -798,18 +820,13 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</select> </select>
</div> </div>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground">
</p>
</section> </section>
<section id="settings-turnstile" className={sectionClass("turnstile")}> <section id="settings-turnstile" className={sectionClass("turnstile")}>
<div className={sectionHeadingClassName}> <div className={sectionHeadingClassName}>
<ShieldAlert className="size-4 text-primary" /> Cloudflare Turnstile <ShieldAlert className="size-4 text-primary" /> Cloudflare Turnstile
<InlineHelp align="start"></InlineHelp>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground">
</p>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="turnstileSiteKey">Site Key</Label> <Label htmlFor="turnstileSiteKey">Site Key</Label>
@@ -821,11 +838,14 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
id="turnstileSecretKey" id="turnstileSecretKey"
name="turnstileSecretKey" name="turnstileSecretKey"
type="password" type="password"
placeholder={config.turnstileSecretConfigured ? "留空保持不变" : "0x4AAAAAAA..."} placeholder={config.turnstileSecretConfigured ? "留空不变" : "0x4AAAAAAA..."}
autoComplete="new-password" autoComplete="new-password"
/> />
{config.turnstileSecretConfigured && ( {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>
</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"> <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"> <span className="flex min-w-0 items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" /> <ScrollText className="size-4 shrink-0 text-primary" />
<span className="truncate"></span> <span className="whitespace-nowrap"></span>
</span> </span>
<span className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground"> <span className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{logs.length} {logs.length}
@@ -137,7 +137,7 @@ function AnalysisLogDetails({ summary }: { summary: SubscriptionRiskGeoSummary }
id={log.id} id={log.id}
target="SUBSCRIPTION_ACCESS_LOGS" target="SUBSCRIPTION_ACCESS_LOGS"
title="删除这条风控访问日志?" title="删除这条风控访问日志?"
description="删除后无法恢复,只会移除这条访问或节点连接证据,不会删除用户订阅或风控事件。" description="删除这条证据,不影响用户订阅。"
successMessage="风控访问日志已删除" successMessage="风控访问日志已删除"
/> />
</div> </div>
@@ -176,7 +176,7 @@ export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionR
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-sm font-semibold"></h3> <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>
</div> </div>
<StatusBadge tone={summary.uniqueCountryCount > 1 ? "danger" : summary.uniqueRegionCount > 1 ? "warning" : "info"}> <StatusBadge tone={summary.uniqueCountryCount > 1 ? "danger" : summary.uniqueRegionCount > 1 ? "warning" : "info"}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ export function TaskLaunchPanel() {
</span> </span>
<div> <div>
<p className="font-semibold"></p> <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> </div>
<PendingSubmitButton size="sm" variant="outline" className="mt-auto w-full" pendingLabel="派发中..."></PendingSubmitButton> <PendingSubmitButton size="sm" variant="outline" className="mt-auto w-full" pendingLabel="派发中..."></PendingSubmitButton>
</form> </form>

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ export function AuthCard({
{PRODUCT_EDITION.slice(0, 1)} {PRODUCT_EDITION.slice(0, 1)}
</div> </div>
{title && <h1 className="text-display text-2xl font-semibold">{title}</h1>} {title && <h1 className="text-display text-2xl font-semibold">{title}</h1>}
{description && <p className="text-sm leading-6 text-muted-foreground">{description}</p>} {description && <p className="mx-auto max-w-xs text-sm leading-6 text-muted-foreground text-pretty">{description}</p>}
</CardHeader> </CardHeader>
)} )}
<CardContent className="pb-6">{children}</CardContent> <CardContent className="pb-6">{children}</CardContent>

View File

@@ -30,13 +30,13 @@ export function ForgotPasswordClient() {
return ( return (
<AuthShell> <AuthShell>
<AuthCard title="找回密码" description="输入注册邮箱,我们会发送一封密码重设邮件。"> <AuthCard title="找回密码" description="输入注册邮箱接收重设邮件。">
{sent ? ( {sent ? (
<div className="space-y-4 py-3 text-center"> <div className="space-y-4 py-3 text-center">
<div className="mx-auto flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary"> <div className="mx-auto flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Mail className="size-5" /> <Mail className="size-5" />
</div> </div>
<p className="text-sm leading-6 text-muted-foreground"> 20 </p> <p className="text-sm leading-6 text-muted-foreground">20 </p>
<Link href="/login" className="text-sm font-medium text-primary hover:underline"></Link> <Link href="/login" className="text-sm font-medium text-primary hover:underline"></Link>
</div> </div>
) : ( ) : (

View File

@@ -30,13 +30,13 @@ export function VerifyEmailRequestClient() {
return ( return (
<AuthShell> <AuthShell>
<AuthCard title="重新发送验证邮件" description="没有收到邮件时,可以重新发送一次。"> <AuthCard title="重新发送验证邮件" description="未收到时可重新发送。">
{sent ? ( {sent ? (
<div className="space-y-4 py-3 text-center"> <div className="space-y-4 py-3 text-center">
<div className="mx-auto flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary"> <div className="mx-auto flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
<MailCheck className="size-5" /> <MailCheck className="size-5" />
</div> </div>
<p className="text-sm leading-6 text-muted-foreground"></p> <p className="text-sm leading-6 text-muted-foreground"></p>
<Link href="/login" className="font-medium text-primary hover:underline"></Link> <Link href="/login" className="font-medium text-primary hover:underline"></Link>
</div> </div>
) : ( ) : (

View File

@@ -8,7 +8,7 @@ import type { PaymentInfo } from "../payment-types";
export function AlipayQrView({ qrCode }: { qrCode: string }) { export function AlipayQrView({ qrCode }: { qrCode: string }) {
return ( return (
<div className="flex flex-col items-center gap-4 rounded-xl border border-border bg-muted/20 p-4"> <div className="flex flex-col items-center gap-4 rounded-xl border border-border bg-muted/20 p-4">
<p className="font-medium">使</p> <p className="font-medium"></p>
<div className="rounded-xl border border-border bg-white p-4"> <div className="rounded-xl border border-border bg-white p-4">
<QRCodeSVG value={qrCode} size={220} /> <QRCodeSVG value={qrCode} size={220} />
</div> </div>
@@ -25,7 +25,7 @@ export function UsdtView({ raw }: { raw: NonNullable<PaymentInfo["raw"]> }) {
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-primary tabular-nums"> <p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-primary tabular-nums">
{raw.usdtAmount} USDT {raw.usdtAmount} USDT
</p> </p>
<p className="mt-1 text-sm text-muted-foreground"></p> <p className="mt-1 text-sm text-muted-foreground"></p>
</div> </div>
<div className="space-y-3 rounded-xl border border-border bg-muted/25 p-4 text-sm"> <div className="space-y-3 rounded-xl border border-border bg-muted/25 p-4 text-sm">

View File

@@ -24,9 +24,7 @@ export function PaymentCard({ title, children }: { title: string; children: Reac
<ShieldCheck className="size-5" /> <ShieldCheck className="size-5" />
</div> </div>
<h1 className="text-display text-2xl font-semibold">{title}</h1> <h1 className="text-display text-2xl font-semibold">{title}</h1>
<p className="mx-auto max-w-md text-sm leading-6 text-muted-foreground"> <p className="mx-auto max-w-md text-sm leading-6 text-muted-foreground"></p>
</p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 pb-6">{children}</CardContent> <CardContent className="space-y-4 pb-6">{children}</CardContent>
</Card> </Card>

View File

@@ -47,7 +47,7 @@ export function PaymentMethodSelector({
<div> <div>
<span className="font-semibold">{provider.name}</span> <span className="font-semibold">{provider.name}</span>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{provider.provider === "usdt_trc20" ? "适合使用稳定币付款" : "根据页面提示完成确认"} {provider.provider === "usdt_trc20" ? "稳定币付款" : "提示完成"}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -13,9 +13,7 @@ export function PaymentSuccessCard({ onDashboard }: { onDashboard: () => void })
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<h1 className="text-display text-2xl font-semibold"></h1> <h1 className="text-display text-2xl font-semibold"></h1>
<p className="mx-auto max-w-sm text-sm leading-6 text-muted-foreground"> <p className="mx-auto max-w-sm text-sm leading-6 text-muted-foreground"></p>
</p>
</div> </div>
<Button size="lg" onClick={onDashboard}></Button> <Button size="lg" onClick={onDashboard}></Button>
</CardContent> </CardContent>

View File

@@ -49,7 +49,7 @@ export function PayPageClient({ orderId }: { orderId: string }) {
)} )}
{status !== "booting" && providers.length === 0 && ( {status !== "booting" && providers.length === 0 && (
<p className="py-4 text-center text-muted-foreground"></p> <p className="py-4 text-center text-muted-foreground"></p>
)} )}
{!payment && status !== "booting" && providers.length > 0 && ( {!payment && status !== "booting" && providers.length > 0 && (
@@ -74,7 +74,7 @@ export function PayPageClient({ orderId }: { orderId: string }) {
{payment && status === "waiting" && ( {payment && status === "waiting" && (
<div className="space-y-3 rounded-lg border border-primary/15 bg-primary/10 px-4 py-3 text-center text-sm text-primary"> <div className="space-y-3 rounded-lg border border-primary/15 bg-primary/10 px-4 py-3 text-center text-sm text-primary">
<p className="animate-pulse font-semibold"></p> <p className="animate-pulse font-semibold"></p>
<p className="text-xs leading-5 text-muted-foreground"></p> <p className="text-xs leading-5 text-muted-foreground"></p>
</div> </div>
)} )}
@@ -101,7 +101,7 @@ export function PayPageClient({ orderId }: { orderId: string }) {
size="lg" size="lg"
variant="destructive" variant="destructive"
title="取消这笔订单?" title="取消这笔订单?"
description="取消会释放本次保留名额,你可以重新选择套餐并创建新的订单。" description="取消会释放本次保留名额。"
confirmLabel="取消订单" confirmLabel="取消订单"
successMessage="订单已取消" successMessage="订单已取消"
errorMessage="取消订单失败" errorMessage="取消订单失败"

View File

@@ -38,7 +38,7 @@ export function AccountInviteCard({
</span> </span>
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription></CardDescription> <CardDescription></CardDescription>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>

View File

@@ -28,7 +28,7 @@ export function AccountPasswordCard({ email, isSaving, onSubmit }: AccountPasswo
</span> </span>
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription>使 6 </CardDescription> <CardDescription></CardDescription>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@@ -51,7 +51,7 @@ export function AccountPasswordCard({ email, isSaving, onSubmit }: AccountPasswo
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="inline-flex items-center gap-2 text-xs leading-5 text-muted-foreground"> <p className="inline-flex items-center gap-2 text-xs leading-5 text-muted-foreground">
<ShieldCheck className="size-3.5 text-primary" /> <ShieldCheck className="size-3.5 text-primary" />
</p> </p>
<Button type="submit" size="lg" disabled={isSaving} className="w-full sm:w-auto"> <Button type="submit" size="lg" disabled={isSaving} className="w-full sm:w-auto">
{isSaving ? "更新中..." : "更新密码"} {isSaving ? "更新中..." : "更新密码"}

View File

@@ -37,7 +37,7 @@ export function AccountProfileCard({
</span> </span>
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription></CardDescription> <CardDescription></CardDescription>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>

View File

@@ -157,7 +157,7 @@ export function CartClient({
</span> </span>
<div> <div>
<h2 className="font-semibold"></h2> <h2 className="font-semibold"></h2>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
</div> </div>
</div> </div>
@@ -173,7 +173,7 @@ export function CartClient({
</div> </div>
)} )}
<div className="border-t border-border/45 pt-3"> <div className="border-t border-border/45 pt-3">
<p className="text-xs leading-5 text-muted-foreground"></p> <p className="text-xs leading-5 text-muted-foreground"></p>
</div> </div>
</div> </div>

View File

@@ -28,7 +28,7 @@ export default async function CartPage() {
eyebrow="购物车" eyebrow="购物车"
icon={<ShoppingCart className="size-5" />} icon={<ShoppingCart className="size-5" />}
title="还没有加入任何套餐" title="还没有加入任何套餐"
description="从商店挑选适合你的连接或服务,加入购物车后再统一结算。" description="加入套餐后可统一结算。"
action={ action={
<Link href="/store" className={buttonVariants()}> <Link href="/store" className={buttonVariants()}>
<ShoppingBag className="size-4" /> <ShoppingBag className="size-4" />

View File

@@ -25,7 +25,7 @@ export function NotificationList({ notifications, unreadCount }: NotificationLis
<EmptyState <EmptyState
icon={<Bell className="size-5" />} icon={<Bell className="size-5" />}
title="现在很安静" title="现在很安静"
description="支付结果、订阅状态和系统提醒会集中出现在这里。" description="支付、订阅和系统提醒会显示在这里。"
action={ action={
<Link href="/store" className={buttonVariants({ variant: "outline" })}> <Link href="/store" className={buttonVariants({ variant: "outline" })}>

View File

@@ -90,7 +90,7 @@ export function NotificationBulkAction({
<ConfirmActionButton <ConfirmActionButton
variant="ghost" variant="ghost"
title="清空已读消息?" title="清空已读消息?"
description="已读消息会从列表中移除,未读消息会继续保留。" description="只移除已读消息。"
confirmLabel="清空已读" confirmLabel="清空已读"
successMessage="已读消息已清空" successMessage="已读消息已清空"
errorMessage="操作失败" errorMessage="操作失败"

View File

@@ -30,7 +30,7 @@ export function UserOrdersTable({ orders }: UserOrdersTableProps) {
<DataTableShell <DataTableShell
isEmpty={orders.length === 0} isEmpty={orders.length === 0}
emptyTitle="还没有订单" emptyTitle="还没有订单"
emptyDescription="选好套餐并提交支付后,你可以在这里继续支付、查看状态和回看记录。" emptyDescription="提交支付后可继续付款和查看状态。"
emptyIcon={<ShoppingBag className="size-5" />} emptyIcon={<ShoppingBag className="size-5" />}
emptyAction={ emptyAction={
<Link href="/store" className={buttonVariants()}> <Link href="/store" className={buttonVariants()}>

View File

@@ -20,7 +20,7 @@ export function UserOrderActions({
variant="ghost" variant="ghost"
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive"
title="取消这笔订单?" title="取消这笔订单?"
description="取消会释放当前保留名额,你可以重新选择套餐或支付方式。" description="取消会释放当前保留名额。"
confirmLabel="取消订单" confirmLabel="取消订单"
successMessage="订单已取消" successMessage="订单已取消"
errorMessage="取消订单失败" errorMessage="取消订单失败"

View File

@@ -132,7 +132,7 @@ export default async function StorePage() {
eyebrow="商店准备中" eyebrow="商店准备中"
icon={<LifeBuoy className="size-5" />} icon={<LifeBuoy className="size-5" />}
title="新的订阅正在准备" title="新的订阅正在准备"
description="可购买套餐会在这里出现。如果你希望提前了解补货时间,可以联系支持团队。" description="可购买套餐会显示在这里。"
action={ action={
<Link href="/support" className={buttonVariants()}> <Link href="/support" className={buttonVariants()}>

View File

@@ -39,7 +39,7 @@ export function PendingOrderBanner({ order }: { order: PendingStoreOrder | null
variant="outline" variant="outline"
size="lg" size="lg"
title="取消这笔订单?" title="取消这笔订单?"
description="取消会释放本次占用名额,你可以重新选择套餐或支付方式。" description="取消会释放本次占用名额。"
confirmLabel="取消订单" confirmLabel="取消订单"
successMessage="订单已取消" successMessage="订单已取消"
errorMessage="取消订单失败" errorMessage="取消订单失败"

View File

@@ -32,7 +32,7 @@ export function ProxyInboundSelect({
</div> </div>
<div> <div>
<p className="text-sm font-semibold">线</p> <p className="text-sm font-semibold">线</p>
<p className="text-xs text-muted-foreground">使</p> <p className="text-xs text-muted-foreground"></p>
</div> </div>
</div> </div>
<Select <Select
@@ -85,7 +85,7 @@ export function ProxyTrafficSlider({ value, min, max, onChange }: ProxyTrafficSl
</div> </div>
<div> <div>
<p className="text-sm font-semibold"></p> <p className="text-sm font-semibold"></p>
<p className="text-xs text-muted-foreground">使</p> <p className="text-xs text-muted-foreground"></p>
</div> </div>
</div> </div>
<div className="rounded-lg border border-primary/15 bg-primary/10 px-3 py-2 text-right text-primary"> <div className="rounded-lg border border-primary/15 bg-primary/10 px-3 py-2 text-right text-primary">
@@ -122,7 +122,7 @@ export function ProxyPurchaseSummary({ totalPrice }: { totalPrice: string }) {
</div> </div>
<div> <div>
<p className="text-sm font-semibold"></p> <p className="text-sm font-semibold"></p>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
</div> </div>
</div> </div>
<span className="text-3xl font-semibold tracking-[-0.06em] text-primary tabular-nums">¥{totalPrice}</span> <span className="text-3xl font-semibold tracking-[-0.06em] text-primary tabular-nums">¥{totalPrice}</span>

View File

@@ -62,7 +62,7 @@ export function ProxySignalPanel({
<p className="inline-flex items-center gap-2 text-sm font-semibold"> <p className="inline-flex items-center gap-2 text-sm font-semibold">
<Activity className="size-4 text-primary" /> 线 <Activity className="size-4 text-primary" /> 线
</p> </p>
<p className="mt-1 text-xs text-muted-foreground">访线</p> <p className="mt-1 text-xs text-muted-foreground"></p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="inline-flex items-center gap-1.5 rounded-full border border-primary/15 bg-primary/10 px-2.5 py-1 text-[0.68rem] font-semibold text-primary"> <div className="inline-flex items-center gap-1.5 rounded-full border border-primary/15 bg-primary/10 px-2.5 py-1 text-[0.68rem] font-semibold text-primary">

View File

@@ -88,7 +88,7 @@ export function StoreLatencyRecommendations({
</div> </div>
<h2 className="text-xl font-semibold tracking-[-0.04em] sm:text-2xl"></h2> <h2 className="text-xl font-semibold tracking-[-0.04em] sm:text-2xl"></h2>
<p className="text-sm leading-6 text-muted-foreground text-pretty"> <p className="text-sm leading-6 text-muted-foreground text-pretty">
线 5 5
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-xs font-semibold text-muted-foreground">

View File

@@ -49,7 +49,7 @@ export default async function UserSubscriptionDetailPage({
<section className="surface-card space-y-4 rounded-xl p-5"> <section className="surface-card space-y-4 rounded-xl p-5">
<SectionHeader <SectionHeader
title="导入与二维码" title="导入与二维码"
description="单节点链接保留在详情页;日常使用建议导入订阅页的总订阅链接。" description="单节点链接保留在详情页。"
/> />
<ProxySubscriptionDetails sub={subscription} baseUrl={baseUrl} /> <ProxySubscriptionDetails sub={subscription} baseUrl={baseUrl} />
</section> </section>
@@ -58,7 +58,7 @@ export default async function UserSubscriptionDetailPage({
<section className="surface-card space-y-4 rounded-xl p-5"> <section className="surface-card space-y-4 rounded-xl p-5">
<SectionHeader <SectionHeader
title="账号凭据" title="账号凭据"
description="只在需要时展开共享账号信息。" description="按需展开共享账号信息。"
/> />
<StreamingCredentialCard subscriptionId={subscription.id} /> <StreamingCredentialCard subscriptionId={subscription.id} />
</section> </section>

View File

@@ -89,7 +89,7 @@ function StreamingCompactSummary({ sub }: { sub: SubscriptionRecord }) {
<Tv className="size-3.5 text-primary" /> <Tv className="size-3.5 text-primary" />
</p> </p>
<p className="mt-1 text-sm font-semibold">{sub.streamingSlot?.service.name ?? "账号分配中"}</p> <p className="mt-1 text-sm font-semibold">{sub.streamingSlot?.service.name ?? "账号分配中"}</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> </div>
); );
} }

View File

@@ -82,7 +82,7 @@ export function ActiveSubscriptionsSection({
eyebrow="下一步" eyebrow="下一步"
icon={<ShoppingBag className="size-5" />} icon={<ShoppingBag className="size-5" />}
title="还没有正在使用的订阅" title="还没有正在使用的订阅"
description="选择套餐并完成支付后,这里会显示统一订阅链接、节点概览和续费入口。" description="支付后显示订阅、节点和续费入口。"
action={ action={
<Link href="/store" className={buttonVariants()}> <Link href="/store" className={buttonVariants()}>
@@ -105,7 +105,7 @@ export function ActiveSubscriptionsSection({
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SectionHeader
title="节点概览" title="节点概览"
description="节点卡片只保留状态、流量和操作;配置、二维码和日志放到详情页。" description="配置、二维码和日志详情页查看。"
actions={<Radio className="size-5 text-primary" />} actions={<Radio className="size-5 text-primary" />}
/> />
{proxyGroups.map((group, index) => ( {proxyGroups.map((group, index) => (

View File

@@ -40,7 +40,7 @@ export function AggregateSubscriptionCard({
</h2> </h2>
<p className="text-sm leading-6 text-muted-foreground text-pretty"> <p className="text-sm leading-6 text-muted-foreground text-pretty">
</p> </p>
</div> </div>
</div> </div>

View File

@@ -22,7 +22,7 @@ export function HistorySubscriptionsSection({
<EmptyState <EmptyState
icon={<Archive className="size-5" />} icon={<Archive className="size-5" />}
title="历史记录还是空的" title="历史记录还是空的"
description="过期、暂停或取消后的订阅会在这里保留记录,方便你之后回看。" description="过期、暂停或取消后会保留记录。"
/> />
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -51,7 +51,7 @@ export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDeta
genericUrl={subUrl} genericUrl={subUrl}
clashUrl={clashUrl} clashUrl={clashUrl}
title="单节点导入" title="单节点导入"
description="适合只导入当前节点;多节点日常使用建议复制总订阅链接。" description="当前节点链接。"
compact compact
/> />
</div> </div>
@@ -71,7 +71,7 @@ export function ProxySubscriptionDetails({ sub, baseUrl }: ProxySubscriptionDeta
</div> </div>
) : ( ) : (
<div className="rounded-lg border border-dashed border-border bg-muted/20 p-3 text-sm leading-6 text-muted-foreground"> <div className="rounded-lg border border-dashed border-border bg-muted/20 p-3 text-sm leading-6 text-muted-foreground">
</div> </div>
)} )}
</div> </div>

View File

@@ -93,9 +93,7 @@ export function RenewalButton({
<WalletCards className="size-3.5" /> RENEWAL <WalletCards className="size-3.5" /> RENEWAL
</div> </div>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
<DialogDescription> <DialogDescription></DialogDescription>
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-5"> <div className="space-y-5">
<div className="rounded-lg border border-border bg-muted/20 p-4"> <div className="rounded-lg border border-border bg-muted/20 p-4">

View File

@@ -14,7 +14,7 @@ export function ResetAccessButton({ subscriptionId }: { subscriptionId: string }
variant="outline" variant="outline"
className="flex-1 sm:flex-none" className="flex-1 sm:flex-none"
title="重置订阅访问?" title="重置订阅访问?"
description="我们会为这条订阅生成新的访问凭据旧链接会失效,请在客户端重新导入。" description="会生成新凭据旧链接随即失效。"
confirmLabel="重置访问" confirmLabel="重置访问"
successMessage="订阅访问已重置" successMessage="订阅访问已重置"
errorMessage="重置失败" errorMessage="重置失败"

View File

@@ -18,7 +18,7 @@ export function StreamingSubscriptionDetails({ sub }: { sub: SubscriptionRecord
{sub.streamingSlot ? ( {sub.streamingSlot ? (
<StreamingCredentialCard subscriptionId={sub.id} /> <StreamingCredentialCard subscriptionId={sub.id} />
) : ( ) : (
<p className="text-sm leading-6 text-muted-foreground"></p> <p className="text-sm leading-6 text-muted-foreground"></p>
)} )}
</div> </div>
); );

View File

@@ -105,9 +105,7 @@ export function TrafficTopupDialog({
<WalletCards className="size-3.5" /> TRAFFIC TOPUP <WalletCards className="size-3.5" /> TRAFFIC TOPUP
</div> </div>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
<DialogDescription> <DialogDescription></DialogDescription>
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-5"> <div className="space-y-5">
<div className="rounded-lg border border-border bg-muted/20 p-4"> <div className="rounded-lg border border-border bg-muted/20 p-4">

View File

@@ -38,7 +38,7 @@ export default async function SubscriptionsPage() {
<PageHeader <PageHeader
eyebrow="订阅管理" eyebrow="订阅管理"
title="我的订阅" title="我的订阅"
description="总订阅链接负责导入全部代理节点;复制 Clash 或通用订阅 URL 后粘贴到客户端。" description="总订阅链接用于导入全部代理节点。"
/> />
<SubscriptionMetrics <SubscriptionMetrics

View File

@@ -59,9 +59,7 @@ export function StreamingCredentialCard({
<CopyButton text={credential.credentials} /> <CopyButton text={credential.credentials} />
</> </>
) : ( ) : (
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground"></p>
</p>
)} )}
</div> </div>
); );

View File

@@ -72,7 +72,7 @@ export function CreateSupportTicketForm({
<div> <div>
<h3 className="text-lg font-semibold"></h3> <h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground"> <p className="mt-1 text-sm leading-6 text-muted-foreground">
{openTicketCount}/{effectiveOpenTicketLimit} {openTicketCount}/{effectiveOpenTicketLimit}
</p> </p>
</div> </div>
</div> </div>
@@ -116,7 +116,7 @@ export function CreateSupportTicketForm({
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<Label htmlFor="subject"></Label> <Label htmlFor="subject"></Label>
<Input id="subject" name="subject" placeholder="一句话描述遇到的问题" defaultValue={preset?.subject} required /> <Input id="subject" name="subject" placeholder="简述问题" defaultValue={preset?.subject} required />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="priority"></Label> <Label htmlFor="priority"></Label>
@@ -135,11 +135,11 @@ export function CreateSupportTicketForm({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="category"></Label> <Label htmlFor="category"></Label>
<Input id="category" name="category" placeholder="例如:支付 / 节点 / 流媒体 / 账户" defaultValue={preset?.category} /> <Input id="category" name="category" placeholder="支付 / 节点 / 账户" defaultValue={preset?.category} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="body"></Label> <Label htmlFor="body"></Label>
<Textarea id="body" name="body" rows={5} placeholder="补充问题背景、错误提示或你已经尝试过的步骤" defaultValue={preset?.body} required /> <Textarea id="body" name="body" rows={5} placeholder="描述现象、错误提示或已尝试步骤" defaultValue={preset?.body} required />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="attachments"> 3 3MB</Label> <Label htmlFor="attachments"> 3 3MB</Label>

View File

@@ -24,12 +24,12 @@ export function SupportTicketReplyForm({ ticketId }: SupportTicketReplyFormProps
</span> </span>
<div> <div>
<h3 className="font-heading text-lg font-semibold tracking-tight"></h3> <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> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="body"></Label> <Label htmlFor="body"></Label>
<Textarea id="body" name="body" rows={4} placeholder="继续补充问题或回复客服团队" required /> <Textarea id="body" name="body" rows={4} placeholder="补充问题或回复客服" required />
</div> </div>
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-4"> <div className="space-y-2 rounded-lg border border-border bg-muted/30 p-4">
<Label htmlFor="reply-attachments" className="inline-flex items-center gap-2"> <Label htmlFor="reply-attachments" className="inline-flex items-center gap-2">
@@ -42,9 +42,7 @@ export function SupportTicketReplyForm({ ticketId }: SupportTicketReplyFormProps
multiple multiple
accept={SUPPORT_ATTACHMENT_ACCEPT} accept={SUPPORT_ATTACHMENT_ACCEPT}
/> />
<p className="field-note"> <p className="field-note"> 3 3MB</p>
JPGPNGWEBPGIFAVIF 3 3MB
</p>
</div> </div>
<PendingSubmitButton size="lg" className="w-full sm:w-auto" pendingLabel="发送中..."></PendingSubmitButton> <PendingSubmitButton size="lg" className="w-full sm:w-auto" pendingLabel="发送中..."></PendingSubmitButton>
</form> </form>

View File

@@ -28,7 +28,7 @@ export function UserSupportTicketTable({ tickets }: UserSupportTicketTableProps)
<DataTableShell <DataTableShell
isEmpty={tickets.length === 0} isEmpty={tickets.length === 0}
emptyTitle="还没有工单" emptyTitle="还没有工单"
emptyDescription="遇到支付、节点、流媒体或账户问题时,提交工单后会在这里跟进处理进度。" emptyDescription="提交后可在这里跟进处理进度。"
emptyIcon={<LifeBuoy className="size-5" />} emptyIcon={<LifeBuoy className="size-5" />}
emptyAction={ emptyAction={
<a href="#new-ticket" className={buttonVariants()}> <a href="#new-ticket" className={buttonVariants()}>

View File

@@ -47,7 +47,7 @@ export default async function SupportPage({
subject: "订阅风控复核申请", subject: "订阅风控复核申请",
category: "订阅风控", category: "订阅风控",
priority: "HIGH" as const, priority: "HIGH" as const,
body: "我需要复核订阅风控限制。\n\n请在这里补充:近期访问订阅的设备、所在城市/国家、是否出差或旅行、是否分享订阅链接或通过其他设备转发节点。\n\n系统判定" + reasonLabel(riskEvent.reason) + "\n" + riskEvent.message, body: "我需要复核订阅风控限制。\n\n请补充:访问设备、所在地区、近期出行、是否分享订阅链接。\n\n系统判定" + reasonLabel(riskEvent.reason) + "\n" + riskEvent.message,
} }
: undefined; : undefined;

View File

@@ -30,7 +30,7 @@ export function VerifyEmailClient({ token }: { token: string }) {
<AuthShell> <AuthShell>
<AuthCard <AuthCard
title={result ? (result.ok ? "验证完成" : "验证失败") : "确认邮箱操作"} title={result ? (result.ok ? "验证完成" : "验证失败") : "确认邮箱操作"}
description={result?.message ?? "为了避免邮件客户端预览误触发,请点击按钮完成确认。"} description={result?.message ?? "点击按钮完成邮箱确认。"}
> >
<div className="space-y-4 py-3 text-center"> <div className="space-y-4 py-3 text-center">
<div className={result && !result.ok ? "mx-auto flex size-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive" : "mx-auto flex size-12 items-center justify-center rounded-xl bg-primary/10 text-primary"}> <div className={result && !result.ok ? "mx-auto flex size-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive" : "mx-auto flex size-12 items-center justify-center rounded-xl bg-primary/10 text-primary"}>

View File

@@ -22,7 +22,7 @@ export function LogDeleteButton({
target, target,
label = "删除", label = "删除",
title = "删除这条日志?", title = "删除这条日志?",
description = "删除后无法恢复,只会移除这条日志记录,不会删除关联业务数据。", description = "删除日志,不影响关联业务。",
successMessage = "日志已删除", successMessage = "日志已删除",
className = "text-destructive hover:text-destructive", className = "text-destructive hover:text-destructive",
size = "xs", size = "xs",

View File

@@ -26,7 +26,7 @@ export function MetricCard({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className={cn("text-2xl font-semibold tabular-nums", valueClassName)}>{value}</p> <p className={cn("text-2xl font-semibold tabular-nums", valueClassName)}>{value}</p>
{description && <p className="mt-1.5 text-xs leading-5 text-muted-foreground text-pretty">{description}</p>} {description && <p className="mt-1 text-xs leading-5 text-muted-foreground text-pretty">{description}</p>}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -59,7 +59,7 @@ export function PageHeader({
{title} {title}
</h1> </h1>
{description && ( {description && (
<p className="max-w-2xl text-sm leading-6 text-muted-foreground text-pretty"> <p className="max-w-xl text-sm leading-6 text-muted-foreground text-pretty">
{description} {description}
</p> </p>
)} )}
@@ -80,7 +80,7 @@ export function SectionHeader({
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<h3 className="text-lg font-semibold tracking-[-0.02em] text-balance">{title}</h3> <h3 className="text-lg font-semibold tracking-[-0.02em] text-balance">{title}</h3>
{description && ( {description && (
<p className="max-w-2xl text-sm leading-6 text-muted-foreground text-pretty"> <p className="max-w-xl text-sm leading-6 text-muted-foreground text-pretty">
{description} {description}
</p> </p>
)} )}

View File

@@ -46,7 +46,7 @@ export function TrafficTrendChart({
<EmptyState <EmptyState
icon={<Activity className="size-5" />} icon={<Activity className="size-5" />}
title="还没有趋势数据" title="还没有趋势数据"
description="同步到客户端流量后,这里会展示近 7 天使用曲线。" description="同步流量后展示近 7 天曲线。"
className="border-0 bg-transparent px-3 py-10" className="border-0 bg-transparent px-3 py-10"
/> />
); );
@@ -58,7 +58,7 @@ export function TrafficTrendChart({
<EmptyState <EmptyState
icon={<Activity className="size-5" />} icon={<Activity className="size-5" />}
title="暂无有效用量" title="暂无有效用量"
description="已有同步记录,但当前周期内没有产生可展示流量。" description="当前周期暂无可展示流量。"
className="border-0 bg-transparent px-3 py-10" className="border-0 bg-transparent px-3 py-10"
/> />
); );

View File

@@ -57,7 +57,7 @@ export function SubscriptionDetailCards({
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold tracking-[-0.02em]"></h3> <h3 className="text-lg font-semibold tracking-[-0.02em]"></h3>
<p className="mt-0.5 text-sm text-muted-foreground"></p> <p className="mt-0.5 text-sm text-muted-foreground"></p>
</div> </div>
</div> </div>
<SubscriptionStatusBadge status={subscription.status} /> <SubscriptionStatusBadge status={subscription.status} />
@@ -86,7 +86,7 @@ export function SubscriptionDetailCards({
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold tracking-[-0.02em]"></h3> <h3 className="text-lg font-semibold tracking-[-0.02em]"></h3>
<p className="mt-0.5 text-sm text-muted-foreground"></p> <p className="mt-0.5 text-sm text-muted-foreground"></p>
</div> </div>
</div> </div>
{subscription.plan.type === "PROXY" ? ( {subscription.plan.type === "PROXY" ? (

View File

@@ -47,14 +47,14 @@ const modes: Record<"OPEN" | "ACKNOWLEDGED", RiskReviewMode> = {
status: "OPEN", status: "OPEN",
label: "重新打开", label: "重新打开",
title: "重新打开风控事件", title: "重新打开风控事件",
description: "事件会回到待处理状态,便于稍后继续跟进。", description: "回到待处理稍后继续跟进。",
icon: "open", icon: "open",
}, },
ACKNOWLEDGED: { ACKNOWLEDGED: {
status: "ACKNOWLEDGED", status: "ACKNOWLEDGED",
label: "确认跟进", label: "确认跟进",
title: "确认正在处理", title: "确认正在处理",
description: "适合先记录已看到、正在核查,暂不解除或关闭事件。", description: "标记已看到,保留后续处置。",
icon: "ack", icon: "ack",
}, },
}; };
@@ -77,8 +77,8 @@ function finalActionCopy(action: SubscriptionRiskFinalAction, restorableSubscrip
label: "解除限制", label: "解除限制",
title: "确认解除风控限制?", title: "确认解除风控限制?",
description: restorableSubscriptionCount > 0 description: restorableSubscriptionCount > 0
? "恢复可恢复的暂停订阅,并关闭用户端强制通知。" ? "恢复可订阅,并关闭强制通知。"
: "关闭用户端强制通知,并把事件记为已解除;当前没有可自动恢复的暂停订阅。", : "关闭强制通知,事件记为已解除。",
confirm: "确认解除", confirm: "确认解除",
}; };
} }
@@ -87,7 +87,7 @@ function finalActionCopy(action: SubscriptionRiskFinalAction, restorableSubscrip
icon: <LockKeyhole className="size-4" />, icon: <LockKeyhole className="size-4" />,
label: "保持封禁/暂停", label: "保持封禁/暂停",
title: "确认保持封禁或暂停?", title: "确认保持封禁或暂停?",
description: "订阅和用户限制会维持当前处置,适合确认订阅链接外泄、公共代理滥用或用户无法解释异常访问来源的情况。", description: "维持当前限制,适合确认共享或滥用。",
confirm: "保持限制", confirm: "保持限制",
}; };
} }
@@ -295,7 +295,7 @@ export function SubscriptionRiskReviewActions({
value={note} value={note}
onChange={(event) => setNote(event.target.value)} onChange={(event) => setNote(event.target.value)}
maxLength={1000} maxLength={1000}
placeholder="例如:已联系用户确认是出差;或确认订阅链接外泄,继续限制。" placeholder="记录处理依据"
/> />
</div> </div>
@@ -323,7 +323,7 @@ export function SubscriptionRiskReviewActions({
<div className="space-y-3"> <div className="space-y-3">
{dialog.action === "RESTORE_ACCESS" && restorableSubscriptionCount > 0 && ( {dialog.action === "RESTORE_ACCESS" && restorableSubscriptionCount > 0 && (
<div className="rounded-lg border border-primary/20 bg-primary/8 p-3 text-sm leading-6 text-primary"> <div className="rounded-lg border border-primary/20 bg-primary/8 p-3 text-sm leading-6 text-primary">
{restorableSubscriptionCount} {restorableSubscriptionCount}
</div> </div>
)} )}
{dialog.action === "KEEP_RESTRICTED" && ( {dialog.action === "KEEP_RESTRICTED" && (
@@ -337,7 +337,7 @@ export function SubscriptionRiskReviewActions({
<span> <span>
<span className="block text-xs text-muted-foreground"> <span className="block text-xs text-muted-foreground">
</span> </span>
</span> </span>
</label> </label>
@@ -349,7 +349,7 @@ export function SubscriptionRiskReviewActions({
value={note} value={note}
onChange={(event) => setNote(event.target.value)} onChange={(event) => setNote(event.target.value)}
maxLength={1000} maxLength={1000}
placeholder="记录最终判断依据,例如:用户提交工单证明为本人出差;或确认链接被多人共享,保持限制。" placeholder="写下最终判断"
/> />
</div> </div>
</div> </div>
@@ -377,9 +377,7 @@ export function SubscriptionRiskReviewActions({
<FileText className="size-4" /> <FileText className="size-4" />
</div> </div>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
<DialogDescription> <DialogDescription></DialogDescription>
</DialogDescription>
</DialogHeader> </DialogHeader>
<pre className="max-h-[28rem] overflow-auto whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-4 text-xs leading-6 text-foreground"> <pre className="max-h-[28rem] overflow-auto whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-4 text-xs leading-6 text-foreground">
{reportPreview || "尚未生成风险报告。"} {reportPreview || "尚未生成风险报告。"}

View File

@@ -7,7 +7,7 @@ export function SubscriptionTimelineSection({ logs }: { logs: AuditLog[] }) {
<section className="surface-card rounded-xl p-5"> <section className="surface-card rounded-xl p-5">
<div className="mb-4"> <div className="mb-4">
<h3 className="text-lg font-semibold tracking-[-0.02em]">线</h3> <h3 className="text-lg font-semibold tracking-[-0.02em]">线</h3>
<p className="mt-0.5 text-sm text-muted-foreground"></p> <p className="mt-0.5 text-sm text-muted-foreground"></p>
</div> </div>
<SubscriptionTimeline <SubscriptionTimeline
items={logs.map((item) => ({ items={logs.map((item) => ({

View File

@@ -18,7 +18,7 @@ export function AdminSupportTicketActions({
size="sm" size="sm"
variant="destructive" variant="destructive"
title="删除这张工单?" title="删除这张工单?"
description="用户对话、附件和关联通知会立即删除,此操作无法恢复。" description="会删除对话、附件和通知,无法恢复。"
confirmLabel="删除工单" confirmLabel="删除工单"
successMessage="工单已删除" successMessage="工单已删除"
errorMessage="删除工单失败" errorMessage="删除工单失败"

View File

@@ -25,7 +25,7 @@ export function UserSupportTicketActions({
size="sm" size="sm"
variant="outline" variant="outline"
title="关闭这张工单?" title="关闭这张工单?"
description="关闭后,这个问题会进入已处理状态。如果后续还有补充,可以再创建新的工单。" description="关闭后归档;有补充可新建工单。"
confirmLabel="关闭工单" confirmLabel="关闭工单"
successMessage="工单已关闭" successMessage="工单已关闭"
errorMessage="关闭工单失败" errorMessage="关闭工单失败"
@@ -40,7 +40,7 @@ export function UserSupportTicketActions({
size="sm" size="sm"
variant="destructive" variant="destructive"
title="删除这张工单?" title="删除这张工单?"
description="工单记录、回复内容和附件会一起删除,此操作无法恢复。" description="会删除记录、回复和附件无法恢复。"
confirmLabel="删除工单" confirmLabel="删除工单"
successMessage="工单已删除" successMessage="工单已删除"
errorMessage="删除工单失败" errorMessage="删除工单失败"

View File

@@ -61,13 +61,13 @@ export function BooleanToggle({
disabled={disabled} disabled={disabled}
onClick={() => select(option.value)} onClick={() => select(option.value)}
className={cn( className={cn(
"min-w-0 flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-60", "min-w-fit flex-1 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-60",
active active
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/55 hover:text-foreground", : "text-muted-foreground hover:bg-background/55 hover:text-foreground",
)} )}
> >
<span className="truncate">{option.label}</span> <span className="block whitespace-nowrap">{option.label}</span>
</button> </button>
); );
})} })}

View File

@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { CircleAlert } from "lucide-react";
import { cn } from "@/lib/utils";
interface InlineHelpProps {
children: ReactNode;
className?: string;
label?: string;
align?: "start" | "center" | "end";
}
const panelAlignClass = {
start: "left-0",
center: "left-1/2 -translate-x-1/2",
end: "right-0",
};
export function InlineHelp({
children,
className,
label = "查看说明",
align = "center",
}: InlineHelpProps) {
return (
<details className={cn("group relative inline-flex shrink-0", className)}>
<summary
aria-label={label}
className="flex size-5 cursor-pointer list-none items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors duration-150 hover:border-primary/30 hover:bg-primary/10 hover:text-primary focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/15 [&::-webkit-details-marker]:hidden"
>
<CircleAlert className="size-3.5" />
</summary>
<div
className={cn(
"absolute top-full z-[70] mt-2 w-56 rounded-lg border border-border bg-popover px-3 py-2 text-xs leading-5 text-popover-foreground shadow-lg",
panelAlignClass[align],
)}
>
{children}
</div>
</details>
);
}

View File

@@ -40,7 +40,7 @@ export function SubscriptionRiskRestrictionGate({
<div> <div>
<p className="font-semibold text-destructive"></p> <p className="font-semibold text-destructive"></p>
<p className="mt-1 text-muted-foreground"> <p className="mt-1 text-muted-foreground">
访 访
</p> </p>
</div> </div>
</div> </div>
@@ -65,7 +65,7 @@ export function SubscriptionRiskRestrictionGate({
<p className="text-xs font-medium text-destructive"></p> <p className="text-xs font-medium text-destructive"></p>
<h2 className="mt-1 text-xl font-semibold tracking-[-0.02em]"></h2> <h2 className="mt-1 text-xl font-semibold tracking-[-0.02em]"></h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground"> <p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
IP 访 IP 访
</p> </p>
</div> </div>
</div> </div>
@@ -92,7 +92,7 @@ export function SubscriptionRiskRestrictionGate({
<div className="flex flex-col gap-3 rounded-xl border border-primary/20 bg-primary/8 p-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 rounded-xl border border-primary/20 bg-primary/8 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm leading-6"> <div className="text-sm leading-6">
<p className="font-semibold text-primary"></p> <p className="font-semibold text-primary"></p>
<p className="text-muted-foreground">访</p> <p className="text-muted-foreground">访</p>
</div> </div>
<Link href={supportHref(restriction.id)} className={buttonVariants({ size: "lg" })}> <Link href={supportHref(restriction.id)} className={buttonVariants({ size: "lg" })}>
<LifeBuoy className="size-4" /> <LifeBuoy className="size-4" />