mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
release: prepare J-Board Lite 3.1.1
This commit is contained in:
@@ -33,6 +33,32 @@ export function AnnouncementsTable({ announcements, users }: AnnouncementsTableP
|
||||
isEmpty={announcements.length === 0}
|
||||
emptyTitle="暂无公告或消息"
|
||||
emptyDescription="发布公告后,会显示展示范围、时间窗口和启用状态。"
|
||||
mobileCards={announcements.map((announcement) => (
|
||||
<article key={announcement.id} className="space-y-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="break-words text-sm font-semibold">{announcement.title}</p>
|
||||
<p className="mt-1 line-clamp-3 whitespace-pre-wrap break-words text-xs leading-5 text-muted-foreground">
|
||||
{announcement.body}
|
||||
</p>
|
||||
</div>
|
||||
<ActiveStatusBadge active={announcement.isActive} activeLabel="启用" inactiveLabel="停用" />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={getAnnouncementAudienceTone(announcement.audience)}>
|
||||
{announcementAudienceLabels[announcement.audience]}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone={announcement.sendNotification ? "info" : "neutral"}>
|
||||
{announcement.sendNotification ? "同步通知" : "不同步"}
|
||||
</StatusBadge>
|
||||
<span className="text-xs text-muted-foreground">{announcementDisplayTypeLabels[announcement.displayType]}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{formatWindow(announcement.startAt, announcement.endAt)}</p>
|
||||
<div className="flex justify-end">
|
||||
<AnnouncementActions announcement={announcement} users={users} />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
>
|
||||
<DataTable aria-label="公告列表" className="min-w-[1040px]">
|
||||
<DataTableHead>
|
||||
|
||||
@@ -25,6 +25,36 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
|
||||
isEmpty={logs.length === 0}
|
||||
emptyTitle="暂无审计日志"
|
||||
emptyDescription="后台关键操作发生后,会记录在这里。"
|
||||
mobileCards={logs.map((log) => (
|
||||
<article key={log.id} className="space-y-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">{formatAuditAction(log.action)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{formatDate(log.createdAt)}</p>
|
||||
</div>
|
||||
<LogDeleteButton
|
||||
id={log.id}
|
||||
target="AUDIT_LOGS"
|
||||
title="删除这条审计日志?"
|
||||
description="删除后无法恢复。系统会记录一条新的删除审计,用于保留后台操作痕迹。"
|
||||
successMessage="审计日志已删除"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 rounded-lg bg-muted/25 p-3 text-xs">
|
||||
<div>
|
||||
<p className="text-muted-foreground">操作者</p>
|
||||
<p className="mt-1 break-all text-sm">{log.actorEmail || "系统"} · {formatAuditActorRole(log.actorRole)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">目标</p>
|
||||
<p className="mt-1 text-sm">{formatAuditTargetType(log.targetType)} · {formatAuditTargetLabel(log)}</p>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-muted-foreground">
|
||||
{formatAuditMessage(log.message)}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
>
|
||||
<DataTable aria-label="审计日志列表" className="min-w-[980px]">
|
||||
<DataTableHead>
|
||||
|
||||
@@ -16,7 +16,7 @@ const NODE_BATCH_FORM_ID = "node-batch-form";
|
||||
|
||||
function PanelInfoBar({ node }: { node: NodeServerRow }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-lg border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-lg bg-muted/25 px-4 py-3 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">3x-ui</span>
|
||||
<span>{node.panelUrl || "未配置面板"}</span>
|
||||
{node.agentToken && <span>探测 Token: 已启用</span>}
|
||||
@@ -74,11 +74,11 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu
|
||||
<CardContent className="space-y-4">
|
||||
<PanelInfoBar node={node} />
|
||||
{node.inbounds.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid gap-2 rounded-lg bg-muted/20 p-3">
|
||||
{node.inbounds.map((inbound) => (
|
||||
<div
|
||||
key={inbound.id}
|
||||
className="flex min-w-72 flex-wrap items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs font-medium"
|
||||
className="flex min-w-0 flex-wrap items-center gap-2 border-b border-border/50 pb-2 text-xs font-medium last:border-b-0 last:pb-0"
|
||||
>
|
||||
<Waypoints className="size-3.5 shrink-0 text-primary" />
|
||||
<span className="shrink-0 text-muted-foreground">{inbound.protocol} · {inbound.port}</span>
|
||||
|
||||
@@ -34,8 +34,8 @@ export default async function NodesPage({
|
||||
value: filters.status,
|
||||
options: [
|
||||
{ label: "全部状态", value: "" },
|
||||
{ label: "active", value: "active" },
|
||||
{ label: "inactive", value: "inactive" },
|
||||
{ label: "已启用", value: "active" },
|
||||
{ label: "已停用", value: "inactive" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -50,6 +50,45 @@ export function OrdersTable({ orders }: OrdersTableProps) {
|
||||
</BatchActionButton>
|
||||
</BatchActionBar>
|
||||
}
|
||||
mobileCards={orders.map((order) => (
|
||||
<article key={order.id} className="space-y-3 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
form="order-batch-form"
|
||||
type="checkbox"
|
||||
name="orderIds"
|
||||
value={order.id}
|
||||
aria-label={`选择订单 ${order.id}`}
|
||||
className="mt-1 size-4 rounded border-border accent-primary"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-all text-sm font-semibold">{order.user.email}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{order.user.name || "未设置昵称"}</p>
|
||||
</div>
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg bg-muted/25 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="break-words text-sm font-medium">{order.plan.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{orderKindLabels[order.kind]} · {formatOrderTraffic(order.trafficGb)}</p>
|
||||
</div>
|
||||
<p className="shrink-0 text-sm font-semibold tabular-nums">{formatOrderAmount(order.amount)}</p>
|
||||
</div>
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{order.paymentMethod || "未选择支付"} · {order.tradeNo || "无交易号"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDateShort(order.createdAt)}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<OrderReviewStatusBadge status={order.reviewStatus} />
|
||||
<OrderReviewActions orderId={order.id} reviewStatus={order.reviewStatus} />
|
||||
</div>
|
||||
<OrderActions orderId={order.id} status={order.status} />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
>
|
||||
<DataTable aria-label="订单列表" className="min-w-[1180px]">
|
||||
<DataTableHead>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, CreditCard, Pencil, ShieldCheck } from "lucide-react";
|
||||
import { savePaymentConfig } from "@/actions/admin/payments";
|
||||
import { ActiveStatusBadge, StatusBadge } from "@/components/shared/status-badge";
|
||||
import { savePaymentConfig, setPaymentConfigEnabled } from "@/actions/admin/payments";
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
import { BooleanToggle } from "@/components/ui/boolean-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -64,24 +64,6 @@ function buildInitialCheckboxValues(fields: Field[], currentConfig?: Record<stri
|
||||
return values;
|
||||
}
|
||||
|
||||
function configCompleteness(fields: Field[], currentConfig: Record<string, string> | undefined, secretConfigured: Record<string, boolean>) {
|
||||
let configured = 0;
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.secret) {
|
||||
if (secretConfigured[field.key]) configured += 1;
|
||||
continue;
|
||||
}
|
||||
if (field.type === "checkboxes") {
|
||||
if (selectedOptionLabels(field, currentConfig?.[field.key]).length > 0) configured += 1;
|
||||
continue;
|
||||
}
|
||||
if (currentConfig?.[field.key]?.trim()) configured += 1;
|
||||
}
|
||||
|
||||
return { configured, total: fields.length };
|
||||
}
|
||||
|
||||
export function PaymentConfigItem({
|
||||
provider,
|
||||
providerName,
|
||||
@@ -95,15 +77,10 @@ export function PaymentConfigItem({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [enabled, setEnabled] = useState(initialEnabled);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [statusSaving, setStatusSaving] = useState(false);
|
||||
const [checkboxValues, setCheckboxValues] = useState<Record<string, Set<string>>>(() =>
|
||||
buildInitialCheckboxValues(fields, currentConfig),
|
||||
);
|
||||
const completeness = useMemo(
|
||||
() => configCompleteness(fields, currentConfig, secretConfigured),
|
||||
[currentConfig, fields, secretConfigured],
|
||||
);
|
||||
const secretFields = fields.filter((field) => field.secret);
|
||||
const configuredSecretCount = secretFields.filter((field) => secretConfigured[field.key]).length;
|
||||
const displayName = currentConfig?.displayName?.trim();
|
||||
const checkboxSummaries = fields
|
||||
.filter((field) => field.type === "checkboxes")
|
||||
@@ -121,9 +98,32 @@ export function PaymentConfigItem({
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStatusToggle(nextEnabled: boolean) {
|
||||
if (statusSaving || enabled === nextEnabled) return;
|
||||
|
||||
const previousEnabled = enabled;
|
||||
setEnabled(nextEnabled);
|
||||
setStatusSaving(true);
|
||||
try {
|
||||
const result = await setPaymentConfigEnabled(provider, nextEnabled);
|
||||
if (!result.ok) {
|
||||
setEnabled(previousEnabled);
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success(`${providerName}${nextEnabled ? "已启用" : "已停用"}`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setEnabled(previousEnabled);
|
||||
toast.error(getErrorMessage(error, `${nextEnabled ? "启用" : "停用"}支付方式失败`));
|
||||
} finally {
|
||||
setStatusSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (saving) return;
|
||||
if (saving || statusSaving) return;
|
||||
|
||||
const form = event.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
@@ -158,7 +158,7 @@ export function PaymentConfigItem({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 border-t border-border/60 px-4 py-4 first:border-t-0 lg:grid-cols-[minmax(0,1fr)_auto_auto] lg:items-center">
|
||||
<section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<CreditCard className="size-4" />
|
||||
@@ -169,22 +169,26 @@ export function PaymentConfigItem({
|
||||
{displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm leading-6 text-muted-foreground">{providerDescription}</p>
|
||||
{checkboxSummaries.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{checkboxSummaries.slice(0, 2).map((label) => (
|
||||
<StatusBadge key={label} tone="info">{label}</StatusBadge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||
<ActiveStatusBadge active={enabled} activeLabel="已启用" inactiveLabel="未启用" />
|
||||
<StatusBadge tone={completeness.configured === completeness.total ? "success" : "neutral"}>
|
||||
配置 {completeness.configured}/{completeness.total}
|
||||
</StatusBadge>
|
||||
{secretFields.length > 0 && (
|
||||
<StatusBadge tone={configuredSecretCount === secretFields.length ? "success" : "warning"}>
|
||||
密钥 {configuredSecretCount}/{secretFields.length}
|
||||
</StatusBadge>
|
||||
)}
|
||||
{checkboxSummaries.slice(0, 2).map((label) => (
|
||||
<StatusBadge key={label} tone="info">{label}</StatusBadge>
|
||||
))}
|
||||
<div className="flex items-center justify-start lg:justify-end">
|
||||
<BooleanToggle
|
||||
className="w-full lg:w-40"
|
||||
value={enabled}
|
||||
onChange={(value) => void handleStatusToggle(value)}
|
||||
trueLabel="启用"
|
||||
falseLabel="停用"
|
||||
ariaLabel={`${providerName}状态`}
|
||||
disabled={saving || statusSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => !saving && setOpen(nextOpen)}>
|
||||
@@ -192,7 +196,7 @@ export function PaymentConfigItem({
|
||||
<Pencil className="size-3.5" />
|
||||
编辑配置
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto bg-card sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<ShieldCheck className="size-4" />
|
||||
@@ -252,17 +256,12 @@ export function PaymentConfigItem({
|
||||
<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>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">启停在列表行即时生效;启用前必须保证必填项完整。</p>
|
||||
</div>
|
||||
<div className="w-full sm:w-56">
|
||||
<BooleanToggle
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
trueLabel="启用"
|
||||
falseLabel="停用"
|
||||
ariaLabel="支付通道状态"
|
||||
disabled={saving}
|
||||
/>
|
||||
<div className="flex justify-start sm:justify-end">
|
||||
<StatusBadge tone={enabled ? "success" : "neutral"}>
|
||||
{enabled ? "已启用" : "已停用"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ export default async function PaymentsPage() {
|
||||
eyebrow="系统"
|
||||
title="支付配置"
|
||||
/>
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
||||
{providerConfigs.map(({ provider, config, secretConfigured }) => (
|
||||
<PaymentConfigItem
|
||||
key={provider.id}
|
||||
|
||||
@@ -161,7 +161,7 @@ export function PlanCard({ plan, activeCount, services, batchFormId }: PlanCardP
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={plan.type === "PROXY" ? "info" : "warning"}>
|
||||
{plan.type === "PROXY" ? "代理套餐" : "流媒体套餐"}
|
||||
</StatusBadge>
|
||||
|
||||
@@ -31,6 +31,35 @@ export function ServicesTable({ services }: { services: StreamingServiceRow[] })
|
||||
<BatchActionButton name="isActive" value="false" destructive>批量停用</BatchActionButton>
|
||||
</BatchActionBar>
|
||||
}
|
||||
mobileCards={services.map((service) => (
|
||||
<article key={service.id} className="space-y-3 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
form="service-batch-form"
|
||||
type="checkbox"
|
||||
name="serviceIds"
|
||||
value={service.id}
|
||||
aria-label={`选择服务 ${service.name}`}
|
||||
className="mt-1 size-4 rounded border-border accent-primary"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<ActiveStatusBadge active={service.isActive} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-muted/25 p-3">
|
||||
<StatusBadge tone={service.usedSlots >= service.maxSlots ? "danger" : "success"}>
|
||||
{service.usedSlots}/{service.maxSlots}
|
||||
</StatusBadge>
|
||||
<span className="text-xs text-muted-foreground">已分配 {service._count.slots} 个订阅槽位</span>
|
||||
<CredentialCell serviceId={service.id} />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<ServiceActions service={service} />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
>
|
||||
<DataTable aria-label="流媒体服务列表" className="min-w-[980px]">
|
||||
<DataTableHead>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { SettingsForm } from "./settings-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -39,7 +40,7 @@ export default async function AdminSettingsPage() {
|
||||
trafficSyncIntervalSeconds: config.trafficSyncIntervalSeconds,
|
||||
logCleanupEnabled: config.logCleanupEnabled,
|
||||
logRetentionDays: config.logRetentionDays,
|
||||
logCleanupLastRunAt: config.logCleanupLastRunAt,
|
||||
logCleanupLastRunAt: config.logCleanupLastRunAt ? formatDate(config.logCleanupLastRunAt) : null,
|
||||
networkRecommendationsEnabled: config.networkRecommendationsEnabled,
|
||||
networkInsightsEnabled: config.networkInsightsEnabled,
|
||||
subscriptionRiskEnabled: config.subscriptionRiskEnabled,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Bell, ChevronDown, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { Bell, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { cleanupExpiredAdminLogs } from "@/actions/admin/logs";
|
||||
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||
import { BooleanToggle } from "@/components/ui/boolean-toggle";
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "@/actions/admin/settings";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
booleanAppSettingLabels,
|
||||
type BooleanAppSettingField,
|
||||
@@ -42,7 +42,7 @@ interface AppConfig {
|
||||
trafficSyncIntervalSeconds: number;
|
||||
logCleanupEnabled: boolean;
|
||||
logRetentionDays: number;
|
||||
logCleanupLastRunAt: Date | string | null;
|
||||
logCleanupLastRunAt: string | null;
|
||||
networkRecommendationsEnabled: boolean;
|
||||
networkInsightsEnabled: boolean;
|
||||
subscriptionRiskEnabled: boolean;
|
||||
@@ -82,6 +82,35 @@ interface CouponOption {
|
||||
}
|
||||
|
||||
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
|
||||
const sectionClassName = "surface-card scroll-mt-24 space-y-4 rounded-xl p-4";
|
||||
const sectionHeadingClassName = "flex items-center gap-2 text-sm font-semibold";
|
||||
|
||||
type SettingsSectionValue =
|
||||
| "basic"
|
||||
| "support"
|
||||
| "automation"
|
||||
| "logs"
|
||||
| "store"
|
||||
| "risk"
|
||||
| "auth"
|
||||
| "email"
|
||||
| "invite"
|
||||
| "turnstile"
|
||||
| "notices";
|
||||
|
||||
const settingsNavItems = [
|
||||
{ value: "basic", label: "基础" },
|
||||
{ value: "support", label: "工单" },
|
||||
{ value: "automation", label: "自动化" },
|
||||
{ value: "logs", label: "日志" },
|
||||
{ value: "store", label: "商城" },
|
||||
{ value: "risk", label: "风控" },
|
||||
{ value: "auth", label: "注册" },
|
||||
{ value: "email", label: "邮件" },
|
||||
{ value: "invite", label: "邀请" },
|
||||
{ value: "turnstile", label: "验证" },
|
||||
{ value: "notices", label: "公告" },
|
||||
] satisfies Array<{ value: SettingsSectionValue; label: string }>;
|
||||
|
||||
const logCleanupTargetOptions = [
|
||||
{ value: "ALL", label: "全部日志" },
|
||||
@@ -123,11 +152,15 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
const [cleaningLogs, setCleaningLogs] = useState(false);
|
||||
const [cleanupTarget, setCleanupTarget] = useState<LogCleanupTarget>("ALL");
|
||||
const [manualCleanupDays, setManualCleanupDays] = useState(config.logRetentionDays);
|
||||
const [riskSettingsOpen, setRiskSettingsOpen] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState<SettingsSectionValue>("basic");
|
||||
const [toggleValues, setToggleValues] = useState<ToggleValues>(() => initialToggleValues(config));
|
||||
const [pendingToggles, setPendingToggles] = useState<Partial<Record<BooleanSettingField, boolean>>>({});
|
||||
const hasPendingToggle = Object.values(pendingToggles).some(Boolean);
|
||||
|
||||
function sectionClass(value: SettingsSectionValue) {
|
||||
return cn(sectionClassName, activeSection !== value && "hidden");
|
||||
}
|
||||
|
||||
function setToggleValue(field: BooleanSettingField, value: boolean) {
|
||||
setToggleValues((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
@@ -277,19 +310,37 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
}
|
||||
|
||||
return (
|
||||
<form id="app-settings-form" onSubmit={handleSubmit} className="form-panel space-y-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<Settings2 className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">全局设置</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">把注册策略、自动化任务和公告内容集中配置,避免页面状态割裂。</p>
|
||||
<form id="app-settings-form" onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="surface-card space-y-4 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<Settings2 className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">全局设置</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">把注册策略、自动化任务和公告内容集中配置,避免页面状态割裂。</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex gap-2 overflow-x-auto pb-1" aria-label="设置分组">
|
||||
{settingsNavItems.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
aria-pressed={activeSection === item.value}
|
||||
onClick={() => setActiveSection(item.value)}
|
||||
className={cn(
|
||||
"btn-base shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium",
|
||||
activeSection === item.value ? "btn-liquid" : "btn-cream",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<section id="settings-basic" className={sectionClass("basic")}>
|
||||
<div className={sectionHeadingClassName}>
|
||||
<Settings2 className="size-4 text-primary" /> 基础信息
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
@@ -314,8 +365,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<section id="settings-support" className={sectionClass("support")}>
|
||||
<div className={sectionHeadingClassName}>
|
||||
<LifeBuoy className="size-4 text-primary" /> 工单售后
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
@@ -337,8 +388,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<section id="settings-automation" className={sectionClass("automation")}>
|
||||
<div className={sectionHeadingClassName}>
|
||||
<Clock3 className="size-4 text-primary" /> 自动化任务
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
@@ -370,8 +421,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<section id="settings-logs" className={sectionClass("logs")}>
|
||||
<div className={sectionHeadingClassName}>
|
||||
<Trash2 className="size-4 text-primary" /> 日志清理
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
@@ -397,7 +448,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
<div className="space-y-2">
|
||||
<Label>上次清理</Label>
|
||||
<div className="flex min-h-10 items-center rounded-lg border border-border bg-background px-3 text-sm text-muted-foreground">
|
||||
{config.logCleanupLastRunAt ? formatDate(config.logCleanupLastRunAt) : "尚未执行"}
|
||||
{config.logCleanupLastRunAt ?? "尚未执行"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -448,8 +499,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<section id="settings-store" className={sectionClass("store")}>
|
||||
<div className={sectionHeadingClassName}>
|
||||
<RadioTower className="size-4 text-primary" /> 商城线路展示
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
@@ -470,179 +521,161 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={riskSettingsOpen}
|
||||
aria-controls="subscription-risk-settings"
|
||||
onClick={() => setRiskSettingsOpen((open) => !open)}
|
||||
className="flex w-full items-center justify-between gap-4 rounded-md text-left outline-none transition-colors hover:text-primary focus-visible:ring-[3px] focus-visible:ring-ring/15"
|
||||
>
|
||||
<span className="flex min-w-0 items-start gap-2">
|
||||
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold">订阅访问风控</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
||||
控制订阅接口限流、跨地区访问告警和自动暂停,当前{toggleValues.subscriptionRiskEnabled ? "已开启" : "已关闭"}。
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
{riskSettingsOpen ? "收起" : "展开"}
|
||||
<ChevronDown className={`size-4 transition-transform ${riskSettingsOpen ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{riskSettingsOpen && (
|
||||
<div id="subscription-risk-settings" className="space-y-4">
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskEnabled">风控总控</Label>
|
||||
{renderImmediateToggle("subscriptionRiskEnabled", { id: "subscriptionRiskEnabled" })}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskAutoSuspend">自动暂停</Label>
|
||||
{renderImmediateToggle("subscriptionRiskAutoSuspend", {
|
||||
id: "subscriptionRiskAutoSuspend",
|
||||
trueLabel: "开启自动封停",
|
||||
falseLabel: "只记录警告",
|
||||
ariaLabel: "自动暂停",
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskWindowHours">统计窗口(小时)</Label>
|
||||
<Input
|
||||
id="subscriptionRiskWindowHours"
|
||||
name="subscriptionRiskWindowHours"
|
||||
type="number"
|
||||
min={1}
|
||||
max={168}
|
||||
defaultValue={config.subscriptionRiskWindowHours}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskCityWarning">城市警告阈值</Label>
|
||||
<Input
|
||||
id="subscriptionRiskCityWarning"
|
||||
name="subscriptionRiskCityWarning"
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
defaultValue={config.subscriptionRiskCityWarning}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskCitySuspend">城市暂停阈值</Label>
|
||||
<Input
|
||||
id="subscriptionRiskCitySuspend"
|
||||
name="subscriptionRiskCitySuspend"
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
defaultValue={config.subscriptionRiskCitySuspend}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskRegionWarning">省/地区警告阈值</Label>
|
||||
<Input
|
||||
id="subscriptionRiskRegionWarning"
|
||||
name="subscriptionRiskRegionWarning"
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
defaultValue={config.subscriptionRiskRegionWarning}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskRegionSuspend">省/地区暂停阈值</Label>
|
||||
<Input
|
||||
id="subscriptionRiskRegionSuspend"
|
||||
name="subscriptionRiskRegionSuspend"
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
defaultValue={config.subscriptionRiskRegionSuspend}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskCountryWarning">国家警告阈值</Label>
|
||||
<Input
|
||||
id="subscriptionRiskCountryWarning"
|
||||
name="subscriptionRiskCountryWarning"
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
defaultValue={config.subscriptionRiskCountryWarning}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskCountrySuspend">国家暂停阈值</Label>
|
||||
<Input
|
||||
id="subscriptionRiskCountrySuspend"
|
||||
name="subscriptionRiskCountrySuspend"
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
defaultValue={config.subscriptionRiskCountrySuspend}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskIpLimitPerHour">IP 限流(次/小时)</Label>
|
||||
<Input
|
||||
id="subscriptionRiskIpLimitPerHour"
|
||||
name="subscriptionRiskIpLimitPerHour"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100000}
|
||||
defaultValue={config.subscriptionRiskIpLimitPerHour}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskTokenLimitPerHour">订阅限流(次/小时)</Label>
|
||||
<Input
|
||||
id="subscriptionRiskTokenLimitPerHour"
|
||||
name="subscriptionRiskTokenLimitPerHour"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100000}
|
||||
defaultValue={config.subscriptionRiskTokenLimitPerHour}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessRiskEnabled">节点日志风控</Label>
|
||||
{renderImmediateToggle("nodeAccessRiskEnabled", {
|
||||
id: "nodeAccessRiskEnabled",
|
||||
trueLabel: "接收日志",
|
||||
falseLabel: "仅订阅风控",
|
||||
ariaLabel: "节点日志风控",
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessConnectionWarning">节点连接警告阈值</Label>
|
||||
<Input id="nodeAccessConnectionWarning" name="nodeAccessConnectionWarning" type="number" min={1} max={100000} defaultValue={config.nodeAccessConnectionWarning} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessConnectionSuspend">节点连接暂停阈值</Label>
|
||||
<Input id="nodeAccessConnectionSuspend" name="nodeAccessConnectionSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessConnectionSuspend} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessUniqueTargetWarning">不同目标警告阈值</Label>
|
||||
<Input id="nodeAccessUniqueTargetWarning" name="nodeAccessUniqueTargetWarning" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetWarning} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessUniqueTargetSuspend">不同目标暂停阈值</Label>
|
||||
<Input id="nodeAccessUniqueTargetSuspend" name="nodeAccessUniqueTargetSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetSuspend} />
|
||||
</div>
|
||||
<section id="settings-risk" className={sectionClass("risk")}>
|
||||
<div className={sectionHeadingClassName}>
|
||||
<ShieldAlert className="size-4 text-primary" /> 订阅访问风控
|
||||
</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">
|
||||
<Label htmlFor="subscriptionRiskEnabled">风控总控</Label>
|
||||
{renderImmediateToggle("subscriptionRiskEnabled", { id: "subscriptionRiskEnabled" })}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskAutoSuspend">自动暂停</Label>
|
||||
{renderImmediateToggle("subscriptionRiskAutoSuspend", {
|
||||
id: "subscriptionRiskAutoSuspend",
|
||||
trueLabel: "开启自动封停",
|
||||
falseLabel: "只记录警告",
|
||||
ariaLabel: "自动暂停",
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskWindowHours">统计窗口(小时)</Label>
|
||||
<Input
|
||||
id="subscriptionRiskWindowHours"
|
||||
name="subscriptionRiskWindowHours"
|
||||
type="number"
|
||||
min={1}
|
||||
max={168}
|
||||
defaultValue={config.subscriptionRiskWindowHours}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskCityWarning">城市警告阈值</Label>
|
||||
<Input
|
||||
id="subscriptionRiskCityWarning"
|
||||
name="subscriptionRiskCityWarning"
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
defaultValue={config.subscriptionRiskCityWarning}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskCitySuspend">城市暂停阈值</Label>
|
||||
<Input
|
||||
id="subscriptionRiskCitySuspend"
|
||||
name="subscriptionRiskCitySuspend"
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
defaultValue={config.subscriptionRiskCitySuspend}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskRegionWarning">省/地区警告阈值</Label>
|
||||
<Input
|
||||
id="subscriptionRiskRegionWarning"
|
||||
name="subscriptionRiskRegionWarning"
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
defaultValue={config.subscriptionRiskRegionWarning}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskRegionSuspend">省/地区暂停阈值</Label>
|
||||
<Input
|
||||
id="subscriptionRiskRegionSuspend"
|
||||
name="subscriptionRiskRegionSuspend"
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
defaultValue={config.subscriptionRiskRegionSuspend}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskCountryWarning">国家警告阈值</Label>
|
||||
<Input
|
||||
id="subscriptionRiskCountryWarning"
|
||||
name="subscriptionRiskCountryWarning"
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
defaultValue={config.subscriptionRiskCountryWarning}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskCountrySuspend">国家暂停阈值</Label>
|
||||
<Input
|
||||
id="subscriptionRiskCountrySuspend"
|
||||
name="subscriptionRiskCountrySuspend"
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
defaultValue={config.subscriptionRiskCountrySuspend}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskIpLimitPerHour">IP 限流(次/小时)</Label>
|
||||
<Input
|
||||
id="subscriptionRiskIpLimitPerHour"
|
||||
name="subscriptionRiskIpLimitPerHour"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100000}
|
||||
defaultValue={config.subscriptionRiskIpLimitPerHour}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subscriptionRiskTokenLimitPerHour">订阅限流(次/小时)</Label>
|
||||
<Input
|
||||
id="subscriptionRiskTokenLimitPerHour"
|
||||
name="subscriptionRiskTokenLimitPerHour"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100000}
|
||||
defaultValue={config.subscriptionRiskTokenLimitPerHour}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessRiskEnabled">节点日志风控</Label>
|
||||
{renderImmediateToggle("nodeAccessRiskEnabled", {
|
||||
id: "nodeAccessRiskEnabled",
|
||||
trueLabel: "接收日志",
|
||||
falseLabel: "仅订阅风控",
|
||||
ariaLabel: "节点日志风控",
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessConnectionWarning">节点连接警告阈值</Label>
|
||||
<Input id="nodeAccessConnectionWarning" name="nodeAccessConnectionWarning" type="number" min={1} max={100000} defaultValue={config.nodeAccessConnectionWarning} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessConnectionSuspend">节点连接暂停阈值</Label>
|
||||
<Input id="nodeAccessConnectionSuspend" name="nodeAccessConnectionSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessConnectionSuspend} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessUniqueTargetWarning">不同目标警告阈值</Label>
|
||||
<Input id="nodeAccessUniqueTargetWarning" name="nodeAccessUniqueTargetWarning" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetWarning} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeAccessUniqueTargetSuspend">不同目标暂停阈值</Label>
|
||||
<Input id="nodeAccessUniqueTargetSuspend" name="nodeAccessUniqueTargetSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetSuspend} />
|
||||
</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>
|
||||
)}
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<section id="settings-auth" className={sectionClass("auth")}>
|
||||
<div className={sectionHeadingClassName}>
|
||||
<ShieldCheck className="size-4 text-primary" /> 注册策略
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
@@ -677,8 +710,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<section id="settings-email" className={sectionClass("email")}>
|
||||
<div className={sectionHeadingClassName}>
|
||||
<Mail className="size-4 text-primary" /> SMTP 邮件服务
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
@@ -735,8 +768,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<section id="settings-invite" className={sectionClass("invite")}>
|
||||
<div className={sectionHeadingClassName}>
|
||||
<Gift className="size-4 text-primary" /> 邀请奖励
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
@@ -770,8 +803,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<section id="settings-turnstile" className={sectionClass("turnstile")}>
|
||||
<div className={sectionHeadingClassName}>
|
||||
<ShieldAlert className="size-4 text-primary" /> Cloudflare Turnstile
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
@@ -798,8 +831,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<section id="settings-notices" className={sectionClass("notices")}>
|
||||
<div className={sectionHeadingClassName}>
|
||||
<Bell className="size-4 text-primary" /> 公告内容
|
||||
</div>
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
|
||||
@@ -80,6 +80,54 @@ export function SubscriptionsTable({
|
||||
</BatchActionButton>
|
||||
</BatchActionBar>
|
||||
}
|
||||
mobileCards={subscriptions.map((subscription) => (
|
||||
<article key={subscription.id} className="space-y-3 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
form="subscription-batch-form"
|
||||
type="checkbox"
|
||||
name="subscriptionIds"
|
||||
value={subscription.id}
|
||||
aria-label={`选择订阅 ${subscription.id}`}
|
||||
className="mt-1 size-4 rounded border-border accent-primary"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/admin/subscriptions/${subscription.id}`}
|
||||
className="break-words text-sm font-semibold hover:underline"
|
||||
>
|
||||
{subscription.plan.name}
|
||||
</Link>
|
||||
<p className="mt-1 break-all text-xs text-muted-foreground">{subscription.user.email}</p>
|
||||
</div>
|
||||
<SubscriptionStatusBadge status={subscription.status} />
|
||||
</div>
|
||||
<div className="space-y-3 rounded-lg bg-muted/25 p-3 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SubscriptionTypeBadge type={subscription.plan.type} />
|
||||
<span className="text-muted-foreground">{formatDateShort(subscription.startDate)} 至 {formatDateShort(subscription.endDate)}</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground">资源</p>
|
||||
<div className="mt-1 text-sm"><SubscriptionResource subscription={subscription} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">流量</p>
|
||||
<p className="mt-1 text-sm"><SubscriptionTraffic subscription={subscription} /></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<AdminSubscriptionActions
|
||||
subscriptionId={subscription.id}
|
||||
status={subscription.status}
|
||||
type={subscription.plan.type}
|
||||
streamingServices={streamingServices}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
>
|
||||
<DataTable aria-label="订阅列表" className="min-w-[1080px]">
|
||||
<DataTableHead>
|
||||
|
||||
@@ -29,6 +29,32 @@ export function AdminSupportTable({ tickets }: AdminSupportTableProps) {
|
||||
isEmpty={tickets.length === 0}
|
||||
emptyTitle="暂无工单"
|
||||
emptyDescription="用户提交售后问题后,会显示在这里。"
|
||||
mobileCards={tickets.map((ticket) => (
|
||||
<article key={ticket.id} className="space-y-3 p-4">
|
||||
<div className="min-w-0">
|
||||
<Link href={`/admin/support/${ticket.id}`} className="break-words text-sm font-semibold hover:underline">
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
<p className="mt-1 break-all text-xs text-muted-foreground">{ticket.user.email}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SupportTicketStatusBadge status={ticket.status} />
|
||||
<SupportTicketPriorityBadge priority={ticket.priority} />
|
||||
<span className="text-xs text-muted-foreground">{ticket._count.replies} 条回复</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(ticket.updatedAt)}</p>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Link
|
||||
href={`/admin/support/${ticket.id}`}
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
查看详情
|
||||
</Link>
|
||||
<AdminSupportTicketActions ticketId={ticket.id} />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
>
|
||||
<DataTable aria-label="后台工单列表" className="min-w-[860px]">
|
||||
<DataTableHead>
|
||||
|
||||
@@ -36,6 +36,55 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) {
|
||||
<BatchActionButton>批量重试失败任务</BatchActionButton>
|
||||
</BatchActionBar>
|
||||
}
|
||||
mobileCards={tasks.map((task) => (
|
||||
<article key={task.id} className="space-y-3 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1 w-4">
|
||||
{task.retryable && task.status === "FAILED" ? (
|
||||
<input
|
||||
form="task-batch-form"
|
||||
type="checkbox"
|
||||
name="taskIds"
|
||||
value={task.id}
|
||||
aria-label={`选择任务 ${task.title}`}
|
||||
className="size-4 rounded border-border accent-primary"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-words text-sm font-semibold">{task.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{taskKindLabels[task.kind]} · {formatDate(task.createdAt)}</p>
|
||||
</div>
|
||||
<TaskStatusBadge status={task.status} />
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/25 p-3 text-xs">
|
||||
<p className="text-muted-foreground">操作者</p>
|
||||
<p className="mt-1 break-all text-sm">{task.triggeredBy?.email ?? "系统"}</p>
|
||||
{task.errorMessage && (
|
||||
<p className="mt-2 whitespace-pre-wrap break-words text-muted-foreground">{task.errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
{task.retryable && task.status === "FAILED" && (
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await retryTaskRun(task.id);
|
||||
}}
|
||||
>
|
||||
<PendingSubmitButton size="sm" variant="outline" pendingLabel="重试中...">重试</PendingSubmitButton>
|
||||
</form>
|
||||
)}
|
||||
<LogDeleteButton
|
||||
id={task.id}
|
||||
target="TASK_RUNS"
|
||||
title="删除这条任务记录?"
|
||||
description="删除后无法恢复,只会移除任务执行记录,不会撤销任务已经产生的业务结果。"
|
||||
successMessage="任务记录已删除"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
>
|
||||
<DataTable aria-label="任务运行列表" className="min-w-[980px]">
|
||||
<DataTableHead>
|
||||
|
||||
@@ -41,6 +41,46 @@ export function TrafficClientsTable({ clients }: TrafficClientsTableProps) {
|
||||
isEmpty={visibleClients.length === 0}
|
||||
emptyTitle="暂无流量数据"
|
||||
emptyDescription="客户端绑定订阅并同步流量后,会显示在这里。"
|
||||
mobileCards={visibleClients.map((client) => {
|
||||
const subscription = client.subscription!;
|
||||
const used = Number(subscription.trafficUsed);
|
||||
const limit = subscription.trafficLimit ? Number(subscription.trafficLimit) : null;
|
||||
|
||||
return (
|
||||
<article key={client.id} className="space-y-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="break-all text-sm font-semibold">{client.user.email}</p>
|
||||
<p className="mt-1 break-all text-xs text-muted-foreground">{client.email}</p>
|
||||
</div>
|
||||
<ActiveStatusBadge active={client.isEnabled} activeLabel="启用" inactiveLabel="禁用" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 rounded-lg bg-muted/25 p-3 text-xs">
|
||||
<div>
|
||||
<p className="text-muted-foreground">节点</p>
|
||||
<p className="mt-1 text-sm">{client.inbound.server.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">协议</p>
|
||||
<div className="mt-1"><StatusBadge tone="neutral">{client.inbound.protocol}</StatusBadge></div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">上传</p>
|
||||
<p className="mt-1 text-sm tabular-nums">{formatBytes(client.trafficUp)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">下载</p>
|
||||
<p className="mt-1 text-sm tabular-nums">{formatBytes(client.trafficDown)}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">已用 / 总量</p>
|
||||
<p className="mt-1 text-sm tabular-nums">{formatBytes(used)} / {limit ? formatBytes(limit) : "无限"}</p>
|
||||
<TrafficUsageBar used={used} limit={limit} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
>
|
||||
<DataTable aria-label="流量客户端列表" className="min-w-[760px]">
|
||||
<DataTableHead>
|
||||
|
||||
@@ -42,6 +42,46 @@ export function UsersTable({ users }: UsersTableProps) {
|
||||
</BatchActionButton>
|
||||
</BatchActionBar>
|
||||
}
|
||||
mobileCards={users.map((user) => (
|
||||
<article key={user.id} className="space-y-3 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
form="user-batch-form"
|
||||
type="checkbox"
|
||||
name="userIds"
|
||||
value={user.id}
|
||||
aria-label={`选择用户 ${user.email}`}
|
||||
className="mt-1 size-4 rounded border-border accent-primary"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-all text-sm font-semibold">{user.email}</p>
|
||||
<p className="mt-1 break-words text-xs text-muted-foreground">{user.name || "未设置昵称"}</p>
|
||||
</div>
|
||||
<UserStatusBadge status={user.status} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 rounded-lg bg-muted/25 p-3 text-xs">
|
||||
<div>
|
||||
<p className="text-muted-foreground">角色</p>
|
||||
<div className="mt-1"><UserRoleBadge role={user.role} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">订阅</p>
|
||||
<p className="mt-1 font-semibold tabular-nums">{user._count.subscriptions}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">邀请</p>
|
||||
<p className="mt-1">{user._count.invitedUsers} 人</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">注册</p>
|
||||
<p className="mt-1">{formatDateShort(user.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<UserActions user={user} />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
>
|
||||
<DataTable aria-label="用户列表" className="min-w-[980px]">
|
||||
<DataTableHead>
|
||||
|
||||
@@ -6,13 +6,14 @@ import { AdminSidebar } from "@/components/admin/sidebar";
|
||||
import { AdminMobileNav } from "@/components/admin/mobile-nav";
|
||||
import { AnnouncementLoader } from "@/components/announcements/announcement-loader";
|
||||
import { PageTransition } from "@/components/shared/page-transition";
|
||||
import { PRODUCT_NAME } from "@/lib/product";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "管理后台",
|
||||
template: "%s | J-Board",
|
||||
template: `%s | ${PRODUCT_NAME}`,
|
||||
},
|
||||
description: "管理用户、订单、套餐、节点和系统配置。",
|
||||
description: "管理 J-Board Lite 用户、订单、套餐、节点和系统配置。",
|
||||
};
|
||||
|
||||
export default async function AdminLayout({
|
||||
|
||||
Reference in New Issue
Block a user