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;
if (!nodeId) throw new Error("代理套餐必须选择节点");
if (data.totalTrafficGb == null || data.totalTrafficGb <= 0) {
throw new Error("代理套餐必须填写总流量池,且大于 0");
}
const inboundIds = parseInboundIds(data.inboundIds, data.inboundId);
if (inboundIds.length === 0) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ export function AuthCard({
{PRODUCT_EDITION.slice(0, 1)}
</div>
{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>
)}
<CardContent className="pb-6">{children}</CardContent>

View File

@@ -30,13 +30,13 @@ export function ForgotPasswordClient() {
return (
<AuthShell>
<AuthCard title="找回密码" description="输入注册邮箱,我们会发送一封密码重设邮件。">
<AuthCard title="找回密码" description="输入注册邮箱接收重设邮件。">
{sent ? (
<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">
<Mail className="size-5" />
</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>
</div>
) : (

View File

@@ -30,13 +30,13 @@ export function VerifyEmailRequestClient() {
return (
<AuthShell>
<AuthCard title="重新发送验证邮件" description="没有收到邮件时,可以重新发送一次。">
<AuthCard title="重新发送验证邮件" description="未收到时可重新发送。">
{sent ? (
<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">
<MailCheck className="size-5" />
</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>
</div>
) : (

View File

@@ -8,7 +8,7 @@ import type { PaymentInfo } from "../payment-types";
export function AlipayQrView({ qrCode }: { qrCode: string }) {
return (
<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">
<QRCodeSVG value={qrCode} size={220} />
</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">
{raw.usdtAmount} USDT
</p>
<p className="mt-1 text-sm text-muted-foreground"></p>
<p className="mt-1 text-sm text-muted-foreground"></p>
</div>
<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" />
</div>
<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>
<p className="mx-auto max-w-md text-sm leading-6 text-muted-foreground"></p>
</CardHeader>
<CardContent className="space-y-4 pb-6">{children}</CardContent>
</Card>

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ export function PayPageClient({ orderId }: { orderId: string }) {
)}
{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 && (
@@ -74,7 +74,7 @@ export function PayPageClient({ orderId }: { orderId: string }) {
{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">
<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>
)}
@@ -101,7 +101,7 @@ export function PayPageClient({ orderId }: { orderId: string }) {
size="lg"
variant="destructive"
title="取消这笔订单?"
description="取消会释放本次保留名额,你可以重新选择套餐并创建新的订单。"
description="取消会释放本次保留名额。"
confirmLabel="取消订单"
successMessage="订单已取消"
errorMessage="取消订单失败"

View File

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

View File

@@ -28,7 +28,7 @@ export function AccountPasswordCard({ email, isSaving, onSubmit }: AccountPasswo
</span>
<div className="min-w-0 space-y-1">
<CardTitle></CardTitle>
<CardDescription>使 6 </CardDescription>
<CardDescription></CardDescription>
</div>
</div>
</CardHeader>
@@ -51,7 +51,7 @@ export function AccountPasswordCard({ email, isSaving, onSubmit }: AccountPasswo
</div>
<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">
<ShieldCheck className="size-3.5 text-primary" />
<ShieldCheck className="size-3.5 text-primary" />
</p>
<Button type="submit" size="lg" disabled={isSaving} className="w-full sm:w-auto">
{isSaving ? "更新中..." : "更新密码"}

View File

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

View File

@@ -157,7 +157,7 @@ export function CartClient({
</span>
<div>
<h2 className="font-semibold"></h2>
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
@@ -173,7 +173,7 @@ export function CartClient({
</div>
)}
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ export function ProxyInboundSelect({
</div>
<div>
<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>
<Select
@@ -85,7 +85,7 @@ export function ProxyTrafficSlider({ value, min, max, onChange }: ProxyTrafficSl
</div>
<div>
<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 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>
<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>
<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">
<Activity className="size-4 text-primary" /> 线
</p>
<p className="mt-1 text-xs text-muted-foreground">访线</p>
<p className="mt-1 text-xs text-muted-foreground"></p>
</div>
<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">

View File

@@ -88,7 +88,7 @@ export function StoreLatencyRecommendations({
</div>
<h2 className="text-xl font-semibold tracking-[-0.04em] sm:text-2xl"></h2>
<p className="text-sm leading-6 text-muted-foreground text-pretty">
线 5
5
</p>
</div>
<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">
<SectionHeader
title="导入与二维码"
description="单节点链接保留在详情页;日常使用建议导入订阅页的总订阅链接。"
description="单节点链接保留在详情页。"
/>
<ProxySubscriptionDetails sub={subscription} baseUrl={baseUrl} />
</section>
@@ -58,7 +58,7 @@ export default async function UserSubscriptionDetailPage({
<section className="surface-card space-y-4 rounded-xl p-5">
<SectionHeader
title="账号凭据"
description="只在需要时展开共享账号信息。"
description="按需展开共享账号信息。"
/>
<StreamingCredentialCard subscriptionId={subscription.id} />
</section>

View File

@@ -89,7 +89,7 @@ function StreamingCompactSummary({ sub }: { sub: SubscriptionRecord }) {
<Tv className="size-3.5 text-primary" />
</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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -93,9 +93,7 @@ export function RenewalButton({
<WalletCards className="size-3.5" /> RENEWAL
</div>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-5">
<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"
className="flex-1 sm:flex-none"
title="重置订阅访问?"
description="我们会为这条订阅生成新的访问凭据旧链接会失效,请在客户端重新导入。"
description="会生成新凭据旧链接随即失效。"
confirmLabel="重置访问"
successMessage="订阅访问已重置"
errorMessage="重置失败"

View File

@@ -18,7 +18,7 @@ export function StreamingSubscriptionDetails({ sub }: { sub: SubscriptionRecord
{sub.streamingSlot ? (
<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>
);

View File

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

View File

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

View File

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

View File

@@ -72,7 +72,7 @@ export function CreateSupportTicketForm({
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{openTicketCount}/{effectiveOpenTicketLimit}
{openTicketCount}/{effectiveOpenTicketLimit}
</p>
</div>
</div>
@@ -116,7 +116,7 @@ export function CreateSupportTicketForm({
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2 md:col-span-2">
<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 className="space-y-2">
<Label htmlFor="priority"></Label>
@@ -135,11 +135,11 @@ export function CreateSupportTicketForm({
</div>
<div className="space-y-2">
<Label htmlFor="category"></Label>
<Input id="category" name="category" placeholder="例如:支付 / 节点 / 流媒体 / 账户" defaultValue={preset?.category} />
<Input id="category" name="category" placeholder="支付 / 节点 / 账户" defaultValue={preset?.category} />
</div>
<div className="space-y-2">
<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 className="space-y-2">
<Label htmlFor="attachments"> 3 3MB</Label>

View File

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

View File

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

View File

@@ -47,7 +47,7 @@ export default async function SupportPage({
subject: "订阅风控复核申请",
category: "订阅风控",
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;

View File

@@ -30,7 +30,7 @@ export function VerifyEmailClient({ token }: { token: string }) {
<AuthShell>
<AuthCard
title={result ? (result.ok ? "验证完成" : "验证失败") : "确认邮箱操作"}
description={result?.message ?? "为了避免邮件客户端预览误触发,请点击按钮完成确认。"}
description={result?.message ?? "点击按钮完成邮箱确认。"}
>
<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"}>

View File

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

View File

@@ -26,7 +26,7 @@ export function MetricCard({
</CardHeader>
<CardContent>
<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>
</Card>
);

View File

@@ -59,7 +59,7 @@ export function PageHeader({
{title}
</h1>
{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}
</p>
)}
@@ -80,7 +80,7 @@ export function SectionHeader({
<div className="min-w-0 space-y-1">
<h3 className="text-lg font-semibold tracking-[-0.02em] text-balance">{title}</h3>
{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}
</p>
)}

View File

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

View File

@@ -57,7 +57,7 @@ export function SubscriptionDetailCards({
</div>
<div>
<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>
<SubscriptionStatusBadge status={subscription.status} />
@@ -86,7 +86,7 @@ export function SubscriptionDetailCards({
</div>
<div>
<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>
{subscription.plan.type === "PROXY" ? (

View File

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

View File

@@ -7,7 +7,7 @@ export function SubscriptionTimelineSection({ logs }: { logs: AuditLog[] }) {
<section className="surface-card rounded-xl p-5">
<div className="mb-4">
<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>
<SubscriptionTimeline
items={logs.map((item) => ({

View File

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

View File

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

View File

@@ -61,13 +61,13 @@ export function BooleanToggle({
disabled={disabled}
onClick={() => select(option.value)}
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
? "bg-background text-foreground shadow-sm"
: "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>
);
})}

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>
<p className="font-semibold text-destructive"></p>
<p className="mt-1 text-muted-foreground">
访
访
</p>
</div>
</div>
@@ -65,7 +65,7 @@ export function SubscriptionRiskRestrictionGate({
<p className="text-xs font-medium text-destructive"></p>
<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">
IP 访
IP 访
</p>
</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="text-sm leading-6">
<p className="font-semibold text-primary"></p>
<p className="text-muted-foreground">访</p>
<p className="text-muted-foreground">访</p>
</div>
<Link href={supportHref(restriction.id)} className={buttonVariants({ size: "lg" })}>
<LifeBuoy className="size-4" />