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

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

View File

@@ -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` | 登录会话密钥 | 生产环境必须使用随机长字符串。 |

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, string>,
@@ -60,3 +71,56 @@ export async function savePaymentConfig(
});
revalidatePath("/admin/payments");
}
export async function setPaymentConfigEnabled(
provider: string,
enabled: boolean,
): Promise<PaymentActionResult> {
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<string, unknown>),
);
} 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, "更新支付开关失败") };
}
}

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,30 +169,34 @@ 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>
</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.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 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)}>
<DialogTrigger render={<Button variant="outline" size="sm" className="w-full lg:w-auto" />}>
<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">
<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 tracking-tight"></h3>
<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,30 +521,13 @@ 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">
<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 ? "已开启" : "已关闭"}
</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 && (
</p>
<div id="subscription-risk-settings" className="space-y-4">
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2">
@@ -638,11 +672,10 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
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({

View File

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

View File

@@ -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: "选择支付方式并完成订单结算。",
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,10 +26,35 @@ 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 (
<form className="surface-card flex flex-col gap-3 rounded-xl p-3 md:flex-row md:flex-wrap md:items-end" role="search">
<form className="surface-card rounded-xl p-3" role="search">
<div className="flex items-center justify-between gap-3 md:hidden">
<button
type="button"
onClick={() => setMobileOpen((open) => !open)}
className="btn-base btn-cream flex h-10 flex-1 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium"
aria-expanded={mobileOpen}
>
{mobileOpen ? <X className="size-4" /> : <SlidersHorizontal className="size-4" />}
{mobileOpen ? "收起筛选" : activeFilterCount > 0 ? `筛选条件 ${activeFilterCount}` : "筛选"}
</button>
{children}
</div>
<div
className={cn(
"flex-col gap-3 md:flex md:flex-row md:flex-wrap md:items-end",
mobileOpen ? "mt-3 flex" : "hidden",
)}
>
<div className="min-w-0 md:min-w-[16rem] md:flex-[1_1_18rem]">
<label className="sr-only" htmlFor="admin-filter-search">
{searchPlaceholder ?? "搜索"}
@@ -34,7 +64,7 @@ export function AdminFilterBar({
name="q"
defaultValue={q ?? ""}
placeholder={searchPlaceholder ?? "搜索"}
className="h-11"
className="h-10 md:h-11"
/>
</div>
{selects.map((select) => (
@@ -46,7 +76,7 @@ export function AdminFilterBar({
id={`admin-filter-${select.name}`}
name={select.name}
defaultValue={select.value}
className="h-11 w-full px-3 text-sm outline-none"
className="h-10 w-full px-3 text-sm outline-none md:h-11"
>
{select.options.map((option) => (
<option key={option.value} value={option.value}>
@@ -56,10 +86,11 @@ export function AdminFilterBar({
</select>
</div>
))}
<Button type="submit" className="h-11 md:flex-none">
<Button type="submit" className="h-10 md:h-11 md:flex-none">
</Button>
{children}
{children && <div className="hidden md:block">{children}</div>}
</div>
</form>
);
}

View File

@@ -1,12 +1,13 @@
"use client";
import { MobileHeader } from "@/components/shared/mobile-header";
import { PRODUCT_NAME } from "@/lib/product";
import { adminNavGroups } from "./sidebar";
export function AdminMobileNav() {
return (
<MobileHeader
title="J-Board"
title={PRODUCT_NAME}
subtitle="管理后台"
groups={adminNavGroups}
collapsibleGroups

View File

@@ -20,6 +20,7 @@ import {
MessagesSquare,
} from "lucide-react";
import { Sidebar, type SidebarGroup, type SidebarLink } from "@/components/shared/sidebar";
import { PRODUCT_NAME } from "@/lib/product";
export const adminLinks: SidebarLink[] = [
{ href: "/admin/dashboard", label: "仪表盘", icon: <BarChart3 size={16} /> },
@@ -70,7 +71,7 @@ export const adminNavGroups: SidebarGroup[] = [
export function AdminSidebar({ onNavigate }: { onNavigate?: () => void } = {}) {
return (
<Sidebar
title="J-Board"
title={PRODUCT_NAME}
subtitle="管理后台"
groups={adminNavGroups}
collapsibleGroups

View File

@@ -4,6 +4,7 @@ import { cn } from "@/lib/utils";
interface DataTableShellProps {
children: ReactNode;
mobileCards?: ReactNode;
toolbar?: ReactNode;
isEmpty?: boolean;
emptyTitle?: string;
@@ -16,6 +17,7 @@ interface DataTableShellProps {
export function DataTableShell({
children,
mobileCards,
toolbar,
isEmpty = false,
emptyTitle = "暂无数据",
@@ -28,8 +30,8 @@ export function DataTableShell({
return (
<div className={cn("table-shell-premium overflow-hidden rounded-xl", className)}>
{toolbar && <div className="border-b border-border/50 bg-muted/20 p-1">{toolbar}</div>}
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-12 bg-gradient-to-l from-card to-transparent md:hidden" />
<div className={cn("relative", mobileCards && "hidden md:block")}>
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-10 bg-card/85 md:hidden" />
<div
aria-label="可横向滚动的数据表"
className="overflow-x-auto focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/20"
@@ -39,7 +41,12 @@ export function DataTableShell({
{children}
</div>
</div>
{scrollHint && !isEmpty && (
{mobileCards && !isEmpty && (
<div className="divide-y divide-border/55 md:hidden">
{mobileCards}
</div>
)}
{scrollHint && !isEmpty && !mobileCards && (
<p className="border-t border-border/40 px-5 py-3 text-xs text-muted-foreground md:hidden">
{scrollHint}
</p>
@@ -51,7 +58,7 @@ export function DataTableShell({
title={emptyTitle}
description={emptyDescription}
action={emptyAction}
className="border-0 bg-transparent py-12 shadow-none"
className="border-0 bg-transparent py-8 shadow-none"
/>
</div>
)}

View File

@@ -13,7 +13,7 @@ export function DataTableHeaderRow({ className, ...props }: ComponentProps<"tr">
return (
<tr
className={cn(
"border-b border-border/60 bg-muted/35 text-left text-[0.7rem] font-semibold uppercase tracking-[0.16em] text-muted-foreground",
"border-b border-border/60 bg-muted/30 text-left text-xs font-semibold text-muted-foreground",
className,
)}
{...props}
@@ -26,7 +26,7 @@ export function DataTableBody({ className, ...props }: ComponentProps<"tbody">)
}
export function DataTableRow({ className, ...props }: ComponentProps<"tr">) {
return <tr className={cn("group/row transition-colors duration-300 hover:bg-primary/[0.035]", className)} {...props} />;
return <tr className={cn("group/row transition-colors duration-150 hover:bg-primary/[0.035]", className)} {...props} />;
}
export function DataTableHeadCell({ className, ...props }: ComponentProps<"th">) {

View File

@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function DetailList({ children, className }: { children: ReactNode; className?: string }) {
return <dl className={cn("grid gap-3 text-sm sm:grid-cols-2", className)}>{children}</dl>;
return <dl className={cn("grid gap-x-6 gap-y-3 text-sm sm:grid-cols-2", className)}>{children}</dl>;
}
export function DetailItem({
@@ -15,8 +15,8 @@ export function DetailItem({
className?: string;
}) {
return (
<div className={cn("rounded-lg border border-border bg-muted/25 px-3 py-2.5", className)}>
<dt className="text-xs font-medium tracking-wide text-muted-foreground">{label}</dt>
<div className={cn("border-b border-border/50 pb-2.5", className)}>
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
<dd className="mt-1.5 min-w-0 font-medium text-foreground text-pretty">{children}</dd>
</div>
);

View File

@@ -18,14 +18,14 @@ export function MetricCard({
valueClassName,
}: MetricCardProps) {
return (
<Card className={cn("min-h-28", className)}>
<Card size="sm" className={cn("min-h-24", className)}>
<CardHeader className="pb-1">
<CardTitle className="text-xs font-medium tracking-wide text-muted-foreground">
<CardTitle className="text-xs font-medium text-muted-foreground">
{label}
</CardTitle>
</CardHeader>
<CardContent>
<p className={cn("text-display text-2xl font-semibold tabular-nums", valueClassName)}>{value}</p>
<p className={cn("text-2xl font-semibold tabular-nums", valueClassName)}>{value}</p>
{description && <p className="mt-1.5 text-xs leading-5 text-muted-foreground text-pretty">{description}</p>}
</CardContent>
</Card>

View File

@@ -13,9 +13,9 @@ export function MobileDrawer({ open, onOpenChange, children }: MobileDrawerProps
return (
<Drawer.Root open={open} onOpenChange={onOpenChange}>
<Drawer.Portal>
<Drawer.Backdrop className="fixed inset-0 z-50 bg-foreground/10 duration-200 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0" />
<Drawer.Viewport className="pointer-events-none fixed inset-0 z-50 p-3">
<Drawer.Popup className="pointer-events-auto h-full w-[15rem] rounded-xl outline-none duration-200 data-open:animate-in data-open:slide-in-from-left-4 data-open:fade-in-0 data-closed:animate-out data-closed:slide-out-to-left-4 data-closed:fade-out-0">
<Drawer.Backdrop className="fixed inset-0 z-40 bg-foreground/18 duration-150 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0" />
<Drawer.Viewport className="pointer-events-none fixed inset-0 z-50 p-3 pt-[calc(0.75rem+env(safe-area-inset-top))]">
<Drawer.Popup className="pointer-events-auto h-full w-[min(19rem,calc(100vw-1.5rem))] rounded-xl outline-none duration-150 data-open:animate-in data-open:slide-in-from-left-4 data-open:fade-in-0 data-closed:animate-out data-closed:slide-out-to-left-4 data-closed:fade-out-0">
{children}
</Drawer.Popup>
</Drawer.Viewport>

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, type ReactNode } from "react";
import { Menu } from "lucide-react";
import { Menu, X } from "lucide-react";
import { MobileDrawer } from "./mobile-drawer";
import { Sidebar, type SidebarGroup, type SidebarLink } from "./sidebar";
import { ThemeToggle } from "./theme-toggle";
@@ -47,6 +47,17 @@ export function MobileHeader({ title, subtitle, links, groups, matchMode, collap
matchMode={matchMode}
collapsibleGroups={collapsibleGroups}
railCollapsible={false}
headerAction={
<button
type="button"
onClick={() => setOpen(false)}
className="btn-base flex size-7 shrink-0 items-center justify-center rounded-md border border-sidebar-border bg-sidebar-accent/35 text-sidebar-foreground/62 hover:bg-sidebar-accent hover:text-sidebar-foreground"
aria-label="关闭菜单"
title="关闭菜单"
>
<X className="size-3.5" />
</button>
}
onNavigate={() => setOpen(false)}
/>
</MobileDrawer>

View File

@@ -34,7 +34,7 @@ interface EmptyStateProps {
export function PageShell({ children, className }: PageShellProps) {
return (
<div className={cn("mx-auto flex w-full max-w-[88rem] flex-col gap-8", className)}>
<div className={cn("mx-auto flex w-full max-w-[88rem] flex-col gap-6 md:gap-7", className)}>
{children}
</div>
);
@@ -103,13 +103,13 @@ export function EmptyState({
return (
<div
className={cn(
"surface-card overflow-hidden rounded-xl border-dashed px-6 py-14 text-center",
"surface-card overflow-hidden rounded-xl border-dashed px-5 py-9 text-center sm:py-10",
className,
)}
>
<div className="mx-auto flex max-w-md flex-col items-center gap-3">
{icon && (
<div className="flex size-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
{icon}
</div>
)}

View File

@@ -52,22 +52,45 @@ export function TrafficTrendChart({
);
}
const maxGb = Math.max(...data.map((point) => Number(point.valueGb) || 0));
if (maxGb <= 0) {
return (
<EmptyState
icon={<Activity className="size-5" />}
title="暂无有效用量"
description="已有同步记录,但当前周期内没有产生可展示的流量。"
className="border-0 bg-transparent px-3 py-10"
/>
);
}
const useMb = maxGb < 1;
const chartData = data.map((point) => ({
...point,
displayValue: useMb ? point.valueGb * 1024 : point.valueGb,
}));
const unit = useMb ? "MB" : "GB";
return (
<div ref={containerRef} className="h-64 min-w-0 overflow-hidden">
{width > 0 ? (
<AreaChart data={data} width={width} height={256}>
<AreaChart data={chartData} width={width} height={256}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis unit=" GB" width={60} />
<YAxis
width={48}
tickFormatter={(value) => `${Number(value).toFixed(useMb ? 0 : 1)}`}
allowDecimals={!useMb}
/>
<Tooltip
labelFormatter={(label) => `日期:${label}`}
formatter={(value) =>
[`${Number(typeof value === "number" ? value : 0).toFixed(2)} GB`, "流量"]
[`${Number(typeof value === "number" ? value : 0).toFixed(useMb ? 0 : 2)} ${unit}`, "流量"]
}
/>
<Area
type="monotone"
dataKey="valueGb"
dataKey="displayValue"
name="流量"
stroke={color}
fill={color}

View File

@@ -31,7 +31,7 @@ function DialogOverlay({
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-foreground/14 duration-300 supports-backdrop-filter:backdrop-blur-md data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
"fixed inset-0 z-40 bg-foreground/28 duration-150 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
@@ -54,7 +54,7 @@ function DialogContent({
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"relative my-auto grid w-full max-w-[calc(100%-2rem)] gap-5 rounded-xl border border-border bg-popover p-6 text-sm text-popover-foreground shadow-lg outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-open:slide-in-from-bottom-2 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:slide-out-to-bottom-2",
"relative my-auto grid w-full max-w-[calc(100%-2rem)] gap-5 rounded-xl border border-border bg-popover p-6 text-sm text-popover-foreground opacity-100 shadow-xl outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-open:slide-in-from-bottom-2 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:slide-out-to-bottom-2",
className
)}
{...props}

View File

@@ -1,13 +1,14 @@
"use client";
import { MobileHeader } from "@/components/shared/mobile-header";
import { PRODUCT_NAME } from "@/lib/product";
import { NotificationPopover } from "./notification-popover";
import { userNavGroups } from "./sidebar";
export function UserMobileNav({ userName, unreadCount }: { userName: string; unreadCount: number }) {
return (
<MobileHeader
title="J-Board"
title={PRODUCT_NAME}
subtitle={userName}
groups={userNavGroups}
matchMode="exact"

View File

@@ -10,6 +10,7 @@ import {
MessageSquareWarning,
} from "lucide-react";
import { Sidebar, type SidebarGroup, type SidebarLink } from "@/components/shared/sidebar";
import { PRODUCT_NAME } from "@/lib/product";
import { NotificationPopover } from "./notification-popover";
export const userLinks: SidebarLink[] = [
@@ -40,7 +41,7 @@ export const userNavGroups: SidebarGroup[] = [
export function UserSidebar({ userName, unreadCount, onNavigate }: { userName: string; unreadCount?: number; onNavigate?: () => void }) {
return (
<Sidebar
title="J-Board"
title={PRODUCT_NAME}
subtitle={userName}
groups={userNavGroups}
matchMode="exact"

View File

@@ -1,15 +1,20 @@
import {
booleanAppSettingLabels,
announcementAudienceLabels,
announcementDisplayTypeLabels,
getBooleanAppSettingLabel,
getPaymentProviderLabel,
getTaskKindLabel,
getUserRoleLabel,
nodeStatusLabels,
orderReviewStatusLabels,
orderStatusLabels,
paymentChannelLabels,
paymentProviderLabels,
subscriptionStatusLabels,
subscriptionTypeLabels,
taskKindLabels,
taskStatusLabels,
userStatusLabels,
} from "@/lib/domain-labels";
@@ -133,13 +138,31 @@ const auditTargetTypeLabels: Record<string, string> = {
const tokenLabels: Record<string, string> = {
...booleanAppSettingLabels,
...announcementAudienceLabels,
...announcementDisplayTypeLabels,
...nodeStatusLabels,
...orderReviewStatusLabels,
...orderStatusLabels,
...paymentChannelLabels,
...paymentProviderLabels,
...subscriptionStatusLabels,
...subscriptionTypeLabels,
...taskKindLabels,
...taskStatusLabels,
...userStatusLabels,
allowTrafficTopup: "允许增流量",
durationDays: "有效期天数",
emailVerificationRequired: "注册邮箱验证",
fixedPrice: "固定价格",
fixedTrafficGb: "固定流量",
inviteRewardRate: "邀请返利比例",
maxTrafficGb: "最大流量",
minTrafficGb: "最小流量",
pricePerGb: "每 GB 价格",
requireInviteCode: "邀请码注册",
totalTrafficGb: "总流量池",
valueGb: "流量值",
valueGB: "流量值",
};
function escapeRegExp(value: string) {

View File

@@ -89,7 +89,7 @@ export function renderRegistrationEmail(siteName: string, actionUrl: string) {
return renderActionEmail({
siteName,
title: "验证你的邮箱",
intro: "欢迎来到 J-Board。点击下方按钮完成邮箱验证验证后即可使用你的账户。",
intro: "欢迎来到 J-Board Lite。点击下方按钮完成邮箱验证,验证后即可使用你的账户。",
actionLabel: "完成邮箱验证",
actionUrl,
note: "链接 30 分钟内有效。为了账户安全,请不要转发这封邮件。",
@@ -111,7 +111,7 @@ export function renderEmailChangeEmail(siteName: string, actionUrl: string) {
return renderActionEmail({
siteName,
title: "确认新的登录邮箱",
intro: "你正在把 J-Board 账户绑定到这个邮箱。点击下方按钮确认变更。",
intro: "你正在把 J-Board Lite 账户绑定到这个邮箱。点击下方按钮确认变更。",
actionLabel: "确认邮箱变更",
actionUrl,
note: "链接 30 分钟内有效。确认后,新邮箱会成为你的登录邮箱。",
@@ -122,8 +122,8 @@ export function renderSmtpTestEmail(siteName: string) {
return renderActionEmail({
siteName,
title: "SMTP 测试邮件",
intro: "这是一封来自 J-Board 的测试邮件。收到它说明当前 SMTP 配置可以正常发信。",
actionLabel: "返回 J-Board",
intro: "这是一封来自 J-Board Lite 的测试邮件。收到它说明当前 SMTP 配置可以正常发信。",
actionLabel: "返回 J-Board Lite",
actionUrl: "https://github.com/JetSprow/J-Board",
note: "你可以回到后台继续配置邮箱验证、密码找回和账户邮箱变更流程。",
closing: "测试完成后,无需回复这封邮件。",

View File

@@ -740,7 +740,7 @@ function buildClashProxy(nodeClient: ProxyNodeContext, target: LinkTarget, clien
function dedupeProxyNames(proxies: JsonRecord[]) {
const seen = new Map<string, number>();
return proxies.map((proxy) => {
const name = asString(proxy.name) ?? "J-Board";
const name = asString(proxy.name) ?? "J-Board Lite";
const count = seen.get(name) ?? 0;
seen.set(name, count + 1);
return count === 0 ? proxy : { ...proxy, name: `${name} ${count + 1}` };
@@ -756,7 +756,7 @@ export function buildClashSubscriptionYaml(nodeClients: ProxyNodeContext[]): str
.filter((item): item is JsonRecord => item != null);
}),
);
const proxyNames = proxies.map((proxy) => asString(proxy.name) ?? "J-Board");
const proxyNames = proxies.map((proxy) => asString(proxy.name) ?? "J-Board Lite");
const selectableNames = proxyNames.length > 0 ? proxyNames : ["DIRECT"];
const config = {
"mixed-port": 7890,