mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
polish: refine lite admin controls
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -63,7 +63,7 @@ export function AnnouncementActions({
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
title="删除这条公告?"
|
||||
description="公告本体和已经同步的站内通知会一起删除,此操作无法恢复。"
|
||||
description="会删除公告和同步通知,无法恢复。"
|
||||
confirmLabel="删除公告"
|
||||
successMessage="公告已删除"
|
||||
errorMessage="删除失败"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="删除线路入口失败"
|
||||
|
||||
@@ -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 log,找到后启用节点日志风控。Agent 只读日志,不修改 3x-ui 配置。
|
||||
Agent 只读日志,用于延迟、路径和风控上报。
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function OrdersTable({ orders }: OrdersTableProps) {
|
||||
<DataTableShell
|
||||
isEmpty={orders.length === 0}
|
||||
emptyTitle="暂无订单"
|
||||
emptyDescription="用户创建订单后,支付和审查状态会出现在这里。"
|
||||
emptyDescription="订单创建后显示支付与审查状态。"
|
||||
toolbar={
|
||||
<BatchActionBar
|
||||
id="order-batch-form"
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function PlanActions({
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
title="彻底删除套餐?"
|
||||
description="关联订阅、本地订单记录和可同步的独占入口会一起处理。此操作无法恢复。"
|
||||
description="会清理关联订阅和订单,无法恢复。"
|
||||
confirmLabel="删除套餐"
|
||||
successMessage="套餐已删除"
|
||||
errorMessage="删除失败"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>>;
|
||||
}) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ServiceActions({ service }: { service: StreamingService }) {
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
title="删除这个服务?"
|
||||
description="删除后无法恢复。请确认没有正在使用这个服务的共享名额。"
|
||||
description="请先确认没有名额正在使用。"
|
||||
confirmLabel="删除服务"
|
||||
successMessage="服务已删除"
|
||||
errorMessage="删除失败"
|
||||
|
||||
@@ -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 password: xxx"
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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="跨地区访问异常会进入人工队列。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export default async function AdminSubscriptionRiskPage({
|
||||
<PageHeader
|
||||
eyebrow="商品与订单"
|
||||
title="订阅风控"
|
||||
description="订阅链接或节点真实连接出现跨城市、跨省份访问异常后,会进入这里供管理员确认、备注、恢复或继续处置。"
|
||||
description="集中处理跨城市、跨省份访问异常。"
|
||||
/>
|
||||
|
||||
<AdminFilterBar
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -66,7 +66,7 @@ export function SubscriptionsTable({
|
||||
<DataTableShell
|
||||
isEmpty={subscriptions.length === 0}
|
||||
emptyTitle="暂无订阅记录"
|
||||
emptyDescription="用户完成购买并开通后,订阅会出现在这里。"
|
||||
emptyDescription="购买开通后订阅会显示在这里。"
|
||||
toolbar={
|
||||
<BatchActionBar
|
||||
id="subscription-batch-form"
|
||||
|
||||
@@ -75,7 +75,7 @@ export function AdminSubscriptionActions({
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
title="彻底删除这个订阅?"
|
||||
description="会同步删除远端客户端,并清理本地记录与相关订单。此操作无法恢复。"
|
||||
description="会删除远端客户端和本地记录,无法恢复。"
|
||||
confirmLabel="删除订阅"
|
||||
successMessage="订阅已删除"
|
||||
errorMessage="删除失败"
|
||||
|
||||
@@ -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">
|
||||
仅支持 JPG、PNG、WEBP、GIF、AVIF 图片,最多 3 张,每张不超过 3MB。
|
||||
</p>
|
||||
<p className="field-note">图片最多 3 张,每张不超过 3MB。</p>
|
||||
</div>
|
||||
<PendingSubmitButton size="lg" className="w-full sm:w-auto" pendingLabel="发送中...">发送回复</PendingSubmitButton>
|
||||
</form>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -24,7 +24,7 @@ export function UsersTable({ users }: UsersTableProps) {
|
||||
<DataTableShell
|
||||
isEmpty={users.length === 0}
|
||||
emptyTitle="暂无用户"
|
||||
emptyDescription="创建用户或等待新用户注册后,会显示在这里。"
|
||||
emptyDescription="创建或注册后用户会显示在这里。"
|
||||
toolbar={
|
||||
<BatchActionBar
|
||||
id="user-batch-form"
|
||||
|
||||
@@ -63,7 +63,7 @@ export function UserActions({ user }: { user: User }) {
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
title="强制删除这个用户?"
|
||||
description="将同步删除该用户在节点面板中的客户端,并永久清理名下订单、订阅、工单、通知、访问日志等数据。此操作不可恢复。"
|
||||
description="会清理节点客户端、订单、订阅、工单和日志,无法恢复。"
|
||||
confirmLabel="强制删除"
|
||||
successMessage="用户已删除"
|
||||
errorMessage="删除失败"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="取消订单失败"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? "更新中..." : "更新密码"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" })}>
|
||||
浏览套餐
|
||||
|
||||
@@ -90,7 +90,7 @@ export function NotificationBulkAction({
|
||||
<ConfirmActionButton
|
||||
variant="ghost"
|
||||
title="清空已读消息?"
|
||||
description="已读消息会从列表中移除,未读消息会继续保留。"
|
||||
description="只移除已读消息。"
|
||||
confirmLabel="清空已读"
|
||||
successMessage="已读消息已清空"
|
||||
errorMessage="操作失败"
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function UserOrderActions({
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
title="取消这笔订单?"
|
||||
description="取消后会释放当前保留的名额,你可以重新选择套餐或支付方式。"
|
||||
description="取消会释放当前保留名额。"
|
||||
confirmLabel="取消订单"
|
||||
successMessage="订单已取消"
|
||||
errorMessage="取消订单失败"
|
||||
|
||||
@@ -132,7 +132,7 @@ export default async function StorePage() {
|
||||
eyebrow="商店准备中"
|
||||
icon={<LifeBuoy className="size-5" />}
|
||||
title="新的订阅正在准备"
|
||||
description="可购买的套餐会在这里出现。如果你希望提前了解补货时间,可以联系支持团队。"
|
||||
description="可购买套餐会显示在这里。"
|
||||
action={
|
||||
<Link href="/support" className={buttonVariants()}>
|
||||
联系支持
|
||||
|
||||
@@ -39,7 +39,7 @@ export function PendingOrderBanner({ order }: { order: PendingStoreOrder | null
|
||||
variant="outline"
|
||||
size="lg"
|
||||
title="取消这笔订单?"
|
||||
description="取消后会释放本次占用的名额,你可以重新选择套餐或支付方式。"
|
||||
description="取消会释放本次占用名额。"
|
||||
confirmLabel="取消订单"
|
||||
successMessage="订单已取消"
|
||||
errorMessage="取消订单失败"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -40,7 +40,7 @@ export function AggregateSubscriptionCard({
|
||||
一个链接导入全部节点
|
||||
</h2>
|
||||
<p className="text-sm leading-6 text-muted-foreground text-pretty">
|
||||
新购买的代理节点会自动加入这个链接;到期或停用的节点会自动从订阅内容中移除,不需要重复导入。
|
||||
节点自动同步,无需重复导入。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function HistorySubscriptionsSection({
|
||||
<EmptyState
|
||||
icon={<Archive className="size-5" />}
|
||||
title="历史记录还是空的"
|
||||
description="过期、暂停或取消后的订阅会在这里保留记录,方便你之后回看。"
|
||||
description="过期、暂停或取消后会保留记录。"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -14,7 +14,7 @@ export function ResetAccessButton({ subscriptionId }: { subscriptionId: string }
|
||||
variant="outline"
|
||||
className="flex-1 sm:flex-none"
|
||||
title="重置订阅访问?"
|
||||
description="我们会为这条订阅生成新的访问凭据。旧链接会失效,请在客户端重新导入。"
|
||||
description="会生成新凭据,旧链接随即失效。"
|
||||
confirmLabel="重置访问"
|
||||
successMessage="订阅访问已重置"
|
||||
errorMessage="重置失败"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -38,7 +38,7 @@ export default async function SubscriptionsPage() {
|
||||
<PageHeader
|
||||
eyebrow="订阅管理"
|
||||
title="我的订阅"
|
||||
description="总订阅链接负责导入全部代理节点;复制 Clash 或通用订阅 URL 后粘贴到客户端。"
|
||||
description="总订阅链接用于导入全部代理节点。"
|
||||
/>
|
||||
|
||||
<SubscriptionMetrics
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
仅支持 JPG、PNG、WEBP、GIF、AVIF 图片,最多 3 张,每张不超过 3MB。
|
||||
</p>
|
||||
<p className="field-note">图片最多 3 张,每张不超过 3MB。</p>
|
||||
</div>
|
||||
<PendingSubmitButton size="lg" className="w-full sm:w-auto" pendingLabel="发送中...">发送回复</PendingSubmitButton>
|
||||
</form>
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function LogDeleteButton({
|
||||
target,
|
||||
label = "删除",
|
||||
title = "删除这条日志?",
|
||||
description = "删除后无法恢复,只会移除这条日志记录,不会删除关联业务数据。",
|
||||
description = "只删除日志,不影响关联业务。",
|
||||
successMessage = "日志已删除",
|
||||
className = "text-destructive hover:text-destructive",
|
||||
size = "xs",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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 || "尚未生成风险报告。"}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -18,7 +18,7 @@ export function AdminSupportTicketActions({
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
title="删除这张工单?"
|
||||
description="用户对话、附件和关联通知会立即删除,此操作无法恢复。"
|
||||
description="会删除对话、附件和通知,无法恢复。"
|
||||
confirmLabel="删除工单"
|
||||
successMessage="工单已删除"
|
||||
errorMessage="删除工单失败"
|
||||
|
||||
@@ -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="删除工单失败"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
42
src/components/ui/inline-help.tsx
Normal file
42
src/components/ui/inline-help.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user