release: prepare J-Board Lite 3.1.1

This commit is contained in:
JetSprow
2026-04-30 20:10:49 +10:00
parent 9d99590338
commit db574ba473
47 changed files with 875 additions and 348 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" },
],
},
]}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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({