From db574ba47341f1eaa9d6f378d890149d8a9ea39a Mon Sep 17 00:00:00 2001 From: JetSprow Date: Thu, 30 Apr 2026 20:10:49 +1000 Subject: [PATCH] release: prepare J-Board Lite 3.1.1 --- .env.example | 2 +- README.md | 2 +- prisma/schema.prisma | 2 +- prisma/seed.ts | 2 +- scripts/install-jboard-panel.sh | 4 +- src/actions/admin/orders.ts | 3 +- src/actions/admin/payments.ts | 64 +++ .../_components/announcements-table.tsx | 26 ++ .../_components/audit-logs-table.tsx | 30 ++ .../nodes/_components/node-card-list.tsx | 6 +- src/app/(admin)/admin/nodes/page.tsx | 4 +- .../admin/orders/_components/orders-table.tsx | 39 ++ .../(admin)/admin/payments/config-form.tsx | 105 +++-- src/app/(admin)/admin/payments/page.tsx | 2 +- src/app/(admin)/admin/plans/plan-card.tsx | 2 +- .../services/_components/services-table.tsx | 29 ++ src/app/(admin)/admin/settings/page.tsx | 3 +- .../(admin)/admin/settings/settings-form.tsx | 433 ++++++++++-------- .../_components/subscriptions-table.tsx | 48 ++ .../_components/admin-support-table.tsx | 26 ++ .../tasks/_components/task-runs-table.tsx | 49 ++ .../_components/traffic-clients-table.tsx | 40 ++ .../admin/users/_components/users-table.tsx | 40 ++ src/app/(admin)/layout.tsx | 5 +- src/app/(auth)/reset-password/page.tsx | 2 +- src/app/(payment)/layout.tsx | 3 +- src/app/(user)/layout.tsx | 5 +- src/app/globals.css | 6 +- src/app/page.tsx | 2 +- src/app/verify-email/page.tsx | 2 +- src/components/admin/filter-bar.tsx | 99 ++-- src/components/admin/mobile-nav.tsx | 3 +- src/components/admin/sidebar.tsx | 3 +- src/components/shared/data-table-shell.tsx | 15 +- src/components/shared/data-table.tsx | 4 +- src/components/shared/detail-list.tsx | 6 +- src/components/shared/metric-card.tsx | 6 +- src/components/shared/mobile-drawer.tsx | 6 +- src/components/shared/mobile-header.tsx | 13 +- src/components/shared/page-shell.tsx | 6 +- src/components/shared/traffic-trend-chart.tsx | 31 +- src/components/ui/dialog.tsx | 4 +- src/components/user/mobile-nav.tsx | 3 +- src/components/user/sidebar.tsx | 3 +- src/lib/audit-display.ts | 23 + src/services/email-templates.ts | 8 +- src/services/subscription.ts | 4 +- 47 files changed, 875 insertions(+), 348 deletions(-) diff --git a/.env.example b/.env.example index 28711fe..6050663 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # J-Board panel APP_PORT="3000" -SITE_NAME="J-Board" +SITE_NAME="J-Board Lite" # SQLite for local tools and Docker DATABASE_URL="file:./storage/jboard.db" diff --git a/README.md b/README.md index a0d4384..8e5453d 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ J-Board Lite 面板和 Agent 使用相对独立的版本节奏。 | 变量 | 用途 | 说明 | | --- | --- | --- | | `APP_PORT` | 面板监听端口 | 默认 `3000`。反向代理应转发到 `http://127.0.0.1:APP_PORT`。 | -| `SITE_NAME` | 站点名称 | 初始化系统设置和邮件模板会使用。 | +| `SITE_NAME` | 站点名称 | 默认 `J-Board Lite`,初始化系统设置和邮件模板会使用。 | | `NEXTAUTH_URL` | 网站访问地址 | 必须填写准备反代到面板的正式域名,例如 `https://panel.example.com`。不要填 `localhost`、容器名或内网地址。 | | `SUBSCRIPTION_URL` | 订阅访问地址 | 可选。用于生成客户端订阅链接,例如 `https://sub.example.com`;留空时复用 `NEXTAUTH_URL`。 | | `NEXTAUTH_SECRET` | 登录会话密钥 | 生产环境必须使用随机长字符串。 | diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 505eb3e..15fd4a1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -772,7 +772,7 @@ model AuditLog { model AppConfig { id String @id @default("default") - siteName String @default("J-Board") + siteName String @default("J-Board Lite") siteUrl String? subscriptionUrl String? allowRegistration Boolean @default(true) diff --git a/prisma/seed.ts b/prisma/seed.ts index 01f5ff3..b407b6e 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -17,7 +17,7 @@ async function main() { const adminEmail = envValue("ADMIN_EMAIL", "admin@jboard.local").toLowerCase(); const adminPassword = process.env.ADMIN_PASSWORD || "admin123"; const adminName = envValue("ADMIN_NAME", "Admin"); - const siteName = envValue("SITE_NAME", "J-Board"); + const siteName = envValue("SITE_NAME", "J-Board Lite"); const siteUrl = process.env.NEXTAUTH_URL?.trim() || null; const subscriptionUrl = process.env.SUBSCRIPTION_URL?.trim() || null; const hashedPassword = await bcrypt.hash(adminPassword, 12); diff --git a/scripts/install-jboard-panel.sh b/scripts/install-jboard-panel.sh index e25aed1..e6f96a8 100755 --- a/scripts/install-jboard-panel.sh +++ b/scripts/install-jboard-panel.sh @@ -275,7 +275,7 @@ load_existing_env() { APP_PORT="${APP_PORT:-3000}" PUBLIC_URL="${NEXTAUTH_URL:-}" SUBSCRIPTION_PUBLIC_URL="${SUBSCRIPTION_URL:-}" - SITE_NAME="${SITE_NAME:-J-Board}" + SITE_NAME="${SITE_NAME:-J-Board Lite}" ADMIN_EMAIL="${ADMIN_EMAIL:-admin@jboard.local}" ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" ADMIN_NAME="${ADMIN_NAME:-Admin}" @@ -338,7 +338,7 @@ configure_env() { ip="$(server_ip)" default_url="http://${ip}:3000" - SITE_NAME="$(prompt_value "站点名称" "J-Board")" + SITE_NAME="$(prompt_value "站点名称" "J-Board Lite")" PUBLIC_URL="$(prompt_value "网站访问地址" "$default_url" "这里请填写你准备反向代理到本机 3000 端口的面板域名,例如 https://panel.example.com。没有域名时可先回车用 IP:3000 测试。")" PUBLIC_URL="$(normalize_url "$PUBLIC_URL")" SUBSCRIPTION_PUBLIC_URL="$(prompt_value "订阅访问地址" "$PUBLIC_URL" "用于生成客户端订阅链接。可以和网站地址相同,也可以填单独反代到本面板的订阅域名,例如 https://sub.example.com。")" diff --git a/src/actions/admin/orders.ts b/src/actions/admin/orders.ts index 30924a9..5e1c3f2 100644 --- a/src/actions/admin/orders.ts +++ b/src/actions/admin/orders.ts @@ -5,6 +5,7 @@ import { requireAdmin } from "@/lib/require-auth"; import { revalidatePath } from "next/cache"; import { confirmPendingOrder } from "@/services/payment/process"; import { actorFromSession, recordAuditLog } from "@/services/audit"; +import { orderReviewStatusLabels } from "@/lib/domain-labels"; export async function confirmOrder(orderId: string) { const session = await requireAdmin(); @@ -66,7 +67,7 @@ export async function updateOrderReview( targetType: "Order", targetId: order.id, targetLabel: order.id, - message: `将订单 ${order.id} 标记为 ${reviewStatus}`, + message: `将订单 ${order.id} 标记为${orderReviewStatusLabels[reviewStatus]}`, }); revalidatePath("/admin/orders"); diff --git a/src/actions/admin/payments.ts b/src/actions/admin/payments.ts index 853f30a..f8e7b95 100644 --- a/src/actions/admin/payments.ts +++ b/src/actions/admin/payments.ts @@ -11,8 +11,19 @@ import { preparePaymentConfigForStorage, } from "@/services/payment/catalog"; import { actorFromSession, recordAuditLog } from "@/services/audit"; +import { getErrorMessage } from "@/lib/errors"; import { z } from "zod"; +type PaymentActionResult = { ok: true } | { ok: false; error: string }; + +function formatPaymentConfigError(error: unknown, fallback: string) { + if (error instanceof z.ZodError) { + const details = error.issues.map((issue) => issue.message).filter(Boolean).join(";"); + return details || getErrorMessage(error, fallback); + } + return getErrorMessage(error, fallback); +} + export async function savePaymentConfig( provider: string, config: Record, @@ -60,3 +71,56 @@ export async function savePaymentConfig( }); revalidatePath("/admin/payments"); } + +export async function setPaymentConfigEnabled( + provider: string, + enabled: boolean, +): Promise { + try { + const session = await requireAdmin(); + const current = await prisma.paymentConfig.findUnique({ + where: { provider }, + select: { config: true, enabled: true }, + }); + + if (!current) { + if (!enabled) return { ok: true }; + throw new Error("请先编辑并保存完整支付配置,再启用该支付方式"); + } + + if (enabled) { + try { + parsePaymentConfig( + provider, + decryptPaymentConfigForUse(provider, current.config as Record), + ); + } catch (error) { + if (error instanceof z.ZodError) { + throw new Error("请先编辑并保存完整支付配置,再启用该支付方式"); + } + throw error; + } + } + + if (current.enabled !== enabled) { + await prisma.paymentConfig.update({ + where: { provider }, + data: { enabled }, + }); + + await recordAuditLog({ + actor: actorFromSession(session), + action: "payment.toggle", + targetType: "PaymentConfig", + targetId: provider, + targetLabel: getPaymentProviderName(provider), + message: `${enabled ? "启用" : "停用"}支付方式 ${getPaymentProviderName(provider)}`, + }); + } + + revalidatePath("/admin/payments"); + return { ok: true }; + } catch (error) { + return { ok: false, error: formatPaymentConfigError(error, "更新支付开关失败") }; + } +} diff --git a/src/app/(admin)/admin/announcements/_components/announcements-table.tsx b/src/app/(admin)/admin/announcements/_components/announcements-table.tsx index c2ccc7c..a05567e 100644 --- a/src/app/(admin)/admin/announcements/_components/announcements-table.tsx +++ b/src/app/(admin)/admin/announcements/_components/announcements-table.tsx @@ -33,6 +33,32 @@ export function AnnouncementsTable({ announcements, users }: AnnouncementsTableP isEmpty={announcements.length === 0} emptyTitle="暂无公告或消息" emptyDescription="发布公告后,会显示展示范围、时间窗口和启用状态。" + mobileCards={announcements.map((announcement) => ( +
+
+
+

{announcement.title}

+

+ {announcement.body} +

+
+ +
+
+ + {announcementAudienceLabels[announcement.audience]} + + + {announcement.sendNotification ? "同步通知" : "不同步"} + + {announcementDisplayTypeLabels[announcement.displayType]} +
+

{formatWindow(announcement.startAt, announcement.endAt)}

+
+ +
+
+ ))} > diff --git a/src/app/(admin)/admin/audit-logs/_components/audit-logs-table.tsx b/src/app/(admin)/admin/audit-logs/_components/audit-logs-table.tsx index ffc1efa..dcd26ec 100644 --- a/src/app/(admin)/admin/audit-logs/_components/audit-logs-table.tsx +++ b/src/app/(admin)/admin/audit-logs/_components/audit-logs-table.tsx @@ -25,6 +25,36 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) { isEmpty={logs.length === 0} emptyTitle="暂无审计日志" emptyDescription="后台关键操作发生后,会记录在这里。" + mobileCards={logs.map((log) => ( +
+
+
+

{formatAuditAction(log.action)}

+

{formatDate(log.createdAt)}

+
+ +
+
+
+

操作者

+

{log.actorEmail || "系统"} · {formatAuditActorRole(log.actorRole)}

+
+
+

目标

+

{formatAuditTargetType(log.targetType)} · {formatAuditTargetLabel(log)}

+
+

+ {formatAuditMessage(log.message)} +

+
+
+ ))} > diff --git a/src/app/(admin)/admin/nodes/_components/node-card-list.tsx b/src/app/(admin)/admin/nodes/_components/node-card-list.tsx index 3fe353a..75ceb11 100644 --- a/src/app/(admin)/admin/nodes/_components/node-card-list.tsx +++ b/src/app/(admin)/admin/nodes/_components/node-card-list.tsx @@ -16,7 +16,7 @@ const NODE_BATCH_FORM_ID = "node-batch-form"; function PanelInfoBar({ node }: { node: NodeServerRow }) { return ( -
+
3x-ui {node.panelUrl || "未配置面板"} {node.agentToken && 探测 Token: 已启用} @@ -74,11 +74,11 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu {node.inbounds.length > 0 ? ( -
+
{node.inbounds.map((inbound) => (
{inbound.protocol} · {inbound.port} diff --git a/src/app/(admin)/admin/nodes/page.tsx b/src/app/(admin)/admin/nodes/page.tsx index 4479636..6246c77 100644 --- a/src/app/(admin)/admin/nodes/page.tsx +++ b/src/app/(admin)/admin/nodes/page.tsx @@ -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" }, ], }, ]} diff --git a/src/app/(admin)/admin/orders/_components/orders-table.tsx b/src/app/(admin)/admin/orders/_components/orders-table.tsx index 856b45e..fb151c2 100644 --- a/src/app/(admin)/admin/orders/_components/orders-table.tsx +++ b/src/app/(admin)/admin/orders/_components/orders-table.tsx @@ -50,6 +50,45 @@ export function OrdersTable({ orders }: OrdersTableProps) { } + mobileCards={orders.map((order) => ( +
+
+ +
+

{order.user.email}

+

{order.user.name || "未设置昵称"}

+
+ +
+
+
+
+

{order.plan.name}

+

{orderKindLabels[order.kind]} · {formatOrderTraffic(order.trafficGb)}

+
+

{formatOrderAmount(order.amount)}

+
+

+ {order.paymentMethod || "未选择支付"} · {order.tradeNo || "无交易号"} +

+

{formatDateShort(order.createdAt)}

+
+
+
+ + +
+ +
+
+ ))} > diff --git a/src/app/(admin)/admin/payments/config-form.tsx b/src/app/(admin)/admin/payments/config-form.tsx index d705a1f..13acc99 100644 --- a/src/app/(admin)/admin/payments/config-form.tsx +++ b/src/app/(admin)/admin/payments/config-form.tsx @@ -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 | undefined, secretConfigured: Record) { - 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>>(() => 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) { 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 ( -
+
@@ -169,22 +169,26 @@ export function PaymentConfigItem({ {displayName && {displayName}}

{providerDescription}

+ {checkboxSummaries.length > 0 && ( +
+ {checkboxSummaries.slice(0, 2).map((label) => ( + {label} + ))} +
+ )}
-
- - - 配置 {completeness.configured}/{completeness.total} - - {secretFields.length > 0 && ( - - 密钥 {configuredSecretCount}/{secretFields.length} - - )} - {checkboxSummaries.slice(0, 2).map((label) => ( - {label} - ))} +
+ void handleStatusToggle(value)} + trueLabel="启用" + falseLabel="停用" + ariaLabel={`${providerName}状态`} + disabled={saving || statusSaving} + />
!saving && setOpen(nextOpen)}> @@ -192,7 +196,7 @@ export function PaymentConfigItem({ 编辑配置 - +
@@ -252,17 +256,12 @@ export function PaymentConfigItem({
-

启用后会出现在用户支付页;启用前必须保证必填项完整。

+

启停在列表行即时生效;启用前必须保证必填项完整。

-
- +
+ + {enabled ? "已启用" : "已停用"} +
diff --git a/src/app/(admin)/admin/payments/page.tsx b/src/app/(admin)/admin/payments/page.tsx index b1ba198..fae4311 100644 --- a/src/app/(admin)/admin/payments/page.tsx +++ b/src/app/(admin)/admin/payments/page.tsx @@ -17,7 +17,7 @@ export default async function PaymentsPage() { eyebrow="系统" title="支付配置" /> -
+
{providerConfigs.map(({ provider, config, secretConfigured }) => (
-
+
{plan.type === "PROXY" ? "代理套餐" : "流媒体套餐"} diff --git a/src/app/(admin)/admin/services/_components/services-table.tsx b/src/app/(admin)/admin/services/_components/services-table.tsx index 219ee92..f0a64f7 100644 --- a/src/app/(admin)/admin/services/_components/services-table.tsx +++ b/src/app/(admin)/admin/services/_components/services-table.tsx @@ -31,6 +31,35 @@ export function ServicesTable({ services }: { services: StreamingServiceRow[] }) 批量停用 } + mobileCards={services.map((service) => ( +
+
+ +
+

{service.name}

+

{service.description || "无描述"}

+
+ +
+
+ = service.maxSlots ? "danger" : "success"}> + {service.usedSlots}/{service.maxSlots} + + 已分配 {service._count.slots} 个订阅槽位 + +
+
+ +
+
+ ))} > diff --git a/src/app/(admin)/admin/settings/page.tsx b/src/app/(admin)/admin/settings/page.tsx index a0d6132..9f56407 100644 --- a/src/app/(admin)/admin/settings/page.tsx +++ b/src/app/(admin)/admin/settings/page.tsx @@ -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, diff --git a/src/app/(admin)/admin/settings/settings-form.tsx b/src/app/(admin)/admin/settings/settings-form.tsx index 1ead2ba..89c0ede 100644 --- a/src/app/(admin)/admin/settings/settings-form.tsx +++ b/src/app/(admin)/admin/settings/settings-form.tsx @@ -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("ALL"); const [manualCleanupDays, setManualCleanupDays] = useState(config.logRetentionDays); - const [riskSettingsOpen, setRiskSettingsOpen] = useState(false); + const [activeSection, setActiveSection] = useState("basic"); const [toggleValues, setToggleValues] = useState(() => initialToggleValues(config)); const [pendingToggles, setPendingToggles] = useState>>({}); 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 ( -
-
- - - -
-

全局设置

-

把注册策略、自动化任务和公告内容集中配置,避免页面状态割裂。

+ +
+
+ + + +
+

全局设置

+

把注册策略、自动化任务和公告内容集中配置,避免页面状态割裂。

+
+
-
-
+
+
基础信息
@@ -314,8 +365,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
-
-
+
+
工单售后
@@ -337,8 +388,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
-
-
+
+
自动化任务
@@ -370,8 +421,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
-
-
+
+
日志清理

@@ -397,7 +448,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:

- {config.logCleanupLastRunAt ? formatDate(config.logCleanupLastRunAt) : "尚未执行"} + {config.logCleanupLastRunAt ?? "尚未执行"}
@@ -448,8 +499,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
-
-
+
+
商城线路展示
@@ -470,179 +521,161 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
-
- - - {riskSettingsOpen && ( -
-
-
- - {renderImmediateToggle("subscriptionRiskEnabled", { id: "subscriptionRiskEnabled" })} -
-
- - {renderImmediateToggle("subscriptionRiskAutoSuspend", { - id: "subscriptionRiskAutoSuspend", - trueLabel: "开启自动封停", - falseLabel: "只记录警告", - ariaLabel: "自动暂停", - })} -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - {renderImmediateToggle("nodeAccessRiskEnabled", { - id: "nodeAccessRiskEnabled", - trueLabel: "接收日志", - falseLabel: "仅订阅风控", - ariaLabel: "节点日志风控", - })} -
-
- - -
-
- - -
-
- - -
-
- - -
+
+
+ 订阅访问风控 +
+

+ 控制订阅接口限流、跨地区访问告警和自动暂停,当前{toggleValues.subscriptionRiskEnabled ? "已开启" : "已关闭"}。 +

+
+
+
+ + {renderImmediateToggle("subscriptionRiskEnabled", { id: "subscriptionRiskEnabled" })} +
+
+ + {renderImmediateToggle("subscriptionRiskAutoSuspend", { + id: "subscriptionRiskAutoSuspend", + trueLabel: "开启自动封停", + falseLabel: "只记录警告", + ariaLabel: "自动暂停", + })} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {renderImmediateToggle("nodeAccessRiskEnabled", { + id: "nodeAccessRiskEnabled", + trueLabel: "接收日志", + falseLabel: "仅订阅风控", + ariaLabel: "节点日志风控", + })} +
+
+ + +
+
+ + +
+
+ + +
+
+ +
-

- 默认值对应原规则:24 小时内 4 城市警告、5 城市暂停;2 省/地区警告、3 省/地区暂停;2 国家警告、3 国家暂停;IP 180 次/小时,订阅 60 次/小时。节点日志风控只在 Agent 配置 XRAY_ACCESS_LOG_PATH 后生效;连接数和不同目标数按 Agent 单次聚合窗口计算。 -

- )} +

+ 默认值对应原规则:24 小时内 4 城市警告、5 城市暂停;2 省/地区警告、3 省/地区暂停;2 国家警告、3 国家暂停;IP 180 次/小时,订阅 60 次/小时。节点日志风控只在 Agent 配置 XRAY_ACCESS_LOG_PATH 后生效;连接数和不同目标数按 Agent 单次聚合窗口计算。 +

+
-
-
+
+
注册策略
@@ -677,8 +710,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
-
-
+
+
SMTP 邮件服务

@@ -735,8 +768,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:

-
-
+
+
邀请奖励
@@ -770,8 +803,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:

-
-
+
+
Cloudflare Turnstile

@@ -798,8 +831,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:

-
-
+
+
公告内容
diff --git a/src/app/(admin)/admin/subscriptions/_components/subscriptions-table.tsx b/src/app/(admin)/admin/subscriptions/_components/subscriptions-table.tsx index 8781b2e..922c108 100644 --- a/src/app/(admin)/admin/subscriptions/_components/subscriptions-table.tsx +++ b/src/app/(admin)/admin/subscriptions/_components/subscriptions-table.tsx @@ -80,6 +80,54 @@ export function SubscriptionsTable({ } + mobileCards={subscriptions.map((subscription) => ( +
+
+ +
+ + {subscription.plan.name} + +

{subscription.user.email}

+
+ +
+
+
+ + {formatDateShort(subscription.startDate)} 至 {formatDateShort(subscription.endDate)} +
+
+
+

资源

+
+
+
+

流量

+

+
+
+
+
+ +
+
+ ))} > diff --git a/src/app/(admin)/admin/support/_components/admin-support-table.tsx b/src/app/(admin)/admin/support/_components/admin-support-table.tsx index 00083d9..fc2e937 100644 --- a/src/app/(admin)/admin/support/_components/admin-support-table.tsx +++ b/src/app/(admin)/admin/support/_components/admin-support-table.tsx @@ -29,6 +29,32 @@ export function AdminSupportTable({ tickets }: AdminSupportTableProps) { isEmpty={tickets.length === 0} emptyTitle="暂无工单" emptyDescription="用户提交售后问题后,会显示在这里。" + mobileCards={tickets.map((ticket) => ( +
+
+ + {ticket.subject} + +

{ticket.user.email}

+
+
+ + + {ticket._count.replies} 条回复 +
+

{formatDate(ticket.updatedAt)}

+
+ + + 查看详情 + + +
+
+ ))} > diff --git a/src/app/(admin)/admin/tasks/_components/task-runs-table.tsx b/src/app/(admin)/admin/tasks/_components/task-runs-table.tsx index b05812d..4c8036d 100644 --- a/src/app/(admin)/admin/tasks/_components/task-runs-table.tsx +++ b/src/app/(admin)/admin/tasks/_components/task-runs-table.tsx @@ -36,6 +36,55 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) { 批量重试失败任务 } + mobileCards={tasks.map((task) => ( +
+
+
+ {task.retryable && task.status === "FAILED" ? ( + + ) : null} +
+
+

{task.title}

+

{taskKindLabels[task.kind]} · {formatDate(task.createdAt)}

+
+ +
+
+

操作者

+

{task.triggeredBy?.email ?? "系统"}

+ {task.errorMessage && ( +

{task.errorMessage}

+ )} +
+
+ {task.retryable && task.status === "FAILED" && ( + { + "use server"; + await retryTaskRun(task.id); + }} + > + 重试 + + )} + +
+
+ ))} > diff --git a/src/app/(admin)/admin/traffic/_components/traffic-clients-table.tsx b/src/app/(admin)/admin/traffic/_components/traffic-clients-table.tsx index b207beb..41b62b7 100644 --- a/src/app/(admin)/admin/traffic/_components/traffic-clients-table.tsx +++ b/src/app/(admin)/admin/traffic/_components/traffic-clients-table.tsx @@ -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 ( +
+
+
+

{client.user.email}

+

{client.email}

+
+ +
+
+
+

节点

+

{client.inbound.server.name}

+
+
+

协议

+
{client.inbound.protocol}
+
+
+

上传

+

{formatBytes(client.trafficUp)}

+
+
+

下载

+

{formatBytes(client.trafficDown)}

+
+
+

已用 / 总量

+

{formatBytes(used)} / {limit ? formatBytes(limit) : "无限"}

+ +
+
+
+ ); + })} > diff --git a/src/app/(admin)/admin/users/_components/users-table.tsx b/src/app/(admin)/admin/users/_components/users-table.tsx index da0e3ee..ba10bd2 100644 --- a/src/app/(admin)/admin/users/_components/users-table.tsx +++ b/src/app/(admin)/admin/users/_components/users-table.tsx @@ -42,6 +42,46 @@ export function UsersTable({ users }: UsersTableProps) { } + mobileCards={users.map((user) => ( +
+
+ +
+

{user.email}

+

{user.name || "未设置昵称"}

+
+ +
+
+
+

角色

+
+
+
+

订阅

+

{user._count.subscriptions}

+
+
+

邀请

+

{user._count.invitedUsers} 人

+
+
+

注册

+

{formatDateShort(user.createdAt)}

+
+
+
+ +
+
+ ))} > diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx index e4b9931..25288d3 100644 --- a/src/app/(admin)/layout.tsx +++ b/src/app/(admin)/layout.tsx @@ -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({ diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx index 3c44139..b8633c8 100644 --- a/src/app/(auth)/reset-password/page.tsx +++ b/src/app/(auth)/reset-password/page.tsx @@ -3,7 +3,7 @@ import { ResetPasswordClient } from "./reset-password-client"; export const metadata: Metadata = { title: "重设密码", - description: "设置新的 J-Board 账户密码。", + description: "设置新的 J-Board Lite 账户密码。", }; export default async function ResetPasswordPage({ diff --git a/src/app/(payment)/layout.tsx b/src/app/(payment)/layout.tsx index b0801df..0f1b4ed 100644 --- a/src/app/(payment)/layout.tsx +++ b/src/app/(payment)/layout.tsx @@ -2,11 +2,12 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { getActiveSession } from "@/lib/require-auth"; import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review"; +import { PRODUCT_NAME } from "@/lib/product"; export const metadata: Metadata = { title: { default: "支付中心", - template: "%s | J-Board", + template: `%s | ${PRODUCT_NAME}`, }, description: "选择支付方式并完成订单结算。", }; diff --git a/src/app/(user)/layout.tsx b/src/app/(user)/layout.tsx index 3f06f83..0284896 100644 --- a/src/app/(user)/layout.tsx +++ b/src/app/(user)/layout.tsx @@ -9,13 +9,14 @@ import { getUnreadNotificationCount } from "./notifications/notifications-data"; import { PageTransition } from "@/components/shared/page-transition"; import { SubscriptionRiskRestrictionGate } from "@/components/user/subscription-risk-restriction-gate"; import { getActiveSubscriptionRiskRestriction, reasonLabel } from "@/services/subscription-risk-review"; +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 UserLayout({ diff --git a/src/app/globals.css b/src/app/globals.css index 9add384..9755ea8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -132,7 +132,7 @@ } .animate-fade-in-up { - animation: fade-in-up 300ms var(--ease-fluid) both; + animation: fade-in-up 180ms var(--ease-fluid) both; } @layer base { @@ -181,7 +181,7 @@ } .surface-lift { - transition: box-shadow 200ms ease, border-color 200ms ease; + transition: box-shadow 150ms ease, border-color 150ms ease; } .surface-lift:hover { @@ -198,7 +198,7 @@ } .text-display { - letter-spacing: -0.035em; + letter-spacing: 0; line-height: 1; } diff --git a/src/app/page.tsx b/src/app/page.tsx index d590e3a..1c382b3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,7 +4,7 @@ import { getActiveSession } from "@/lib/require-auth"; export const metadata: Metadata = { title: "首页", - description: "J-Board 首页路由,会根据身份跳转到对应工作台。", + description: "J-Board Lite 首页路由,会根据身份跳转到对应工作台。", }; export default async function Home() { diff --git a/src/app/verify-email/page.tsx b/src/app/verify-email/page.tsx index d2310d4..6b37e08 100644 --- a/src/app/verify-email/page.tsx +++ b/src/app/verify-email/page.tsx @@ -3,7 +3,7 @@ import { VerifyEmailClient } from "./verify-email-client"; export const metadata: Metadata = { title: "邮箱验证", - description: "确认 J-Board 账户邮箱。", + description: "确认 J-Board Lite 账户邮箱。", }; export default async function VerifyEmailPage({ diff --git a/src/components/admin/filter-bar.tsx b/src/components/admin/filter-bar.tsx index 606c8c0..dadf400 100644 --- a/src/components/admin/filter-bar.tsx +++ b/src/components/admin/filter-bar.tsx @@ -1,5 +1,10 @@ +"use client"; + +import { useMemo, useState, type ReactNode } from "react"; +import { SlidersHorizontal, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; export interface AdminFilterOption { label: string; @@ -21,45 +26,71 @@ export function AdminFilterBar({ q?: string; searchPlaceholder?: string; selects?: AdminFilterSelect[]; - children?: React.ReactNode; + children?: ReactNode; }) { + const activeFilterCount = useMemo(() => { + const searchActive = q?.trim() ? 1 : 0; + const selectActive = selects.filter((select) => select.value && select.value !== "").length; + return searchActive + selectActive; + }, [q, selects]); + const [mobileOpen, setMobileOpen] = useState(activeFilterCount > 0); + return ( -
-
- - + +
+ + {children}
- {selects.map((select) => ( -
-