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 # J-Board panel
APP_PORT="3000" APP_PORT="3000"
SITE_NAME="J-Board" SITE_NAME="J-Board Lite"
# SQLite for local tools and Docker # SQLite for local tools and Docker
DATABASE_URL="file:./storage/jboard.db" 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`。 | | `APP_PORT` | 面板监听端口 | 默认 `3000`。反向代理应转发到 `http://127.0.0.1:APP_PORT`。 |
| `SITE_NAME` | 站点名称 | 初始化系统设置和邮件模板会使用。 | | `SITE_NAME` | 站点名称 | 默认 `J-Board Lite`初始化系统设置和邮件模板会使用。 |
| `NEXTAUTH_URL` | 网站访问地址 | 必须填写准备反代到面板的正式域名,例如 `https://panel.example.com`。不要填 `localhost`、容器名或内网地址。 | | `NEXTAUTH_URL` | 网站访问地址 | 必须填写准备反代到面板的正式域名,例如 `https://panel.example.com`。不要填 `localhost`、容器名或内网地址。 |
| `SUBSCRIPTION_URL` | 订阅访问地址 | 可选。用于生成客户端订阅链接,例如 `https://sub.example.com`;留空时复用 `NEXTAUTH_URL`。 | | `SUBSCRIPTION_URL` | 订阅访问地址 | 可选。用于生成客户端订阅链接,例如 `https://sub.example.com`;留空时复用 `NEXTAUTH_URL`。 |
| `NEXTAUTH_SECRET` | 登录会话密钥 | 生产环境必须使用随机长字符串。 | | `NEXTAUTH_SECRET` | 登录会话密钥 | 生产环境必须使用随机长字符串。 |

View File

@@ -772,7 +772,7 @@ model AuditLog {
model AppConfig { model AppConfig {
id String @id @default("default") id String @id @default("default")
siteName String @default("J-Board") siteName String @default("J-Board Lite")
siteUrl String? siteUrl String?
subscriptionUrl String? subscriptionUrl String?
allowRegistration Boolean @default(true) allowRegistration Boolean @default(true)

View File

@@ -17,7 +17,7 @@ async function main() {
const adminEmail = envValue("ADMIN_EMAIL", "admin@jboard.local").toLowerCase(); const adminEmail = envValue("ADMIN_EMAIL", "admin@jboard.local").toLowerCase();
const adminPassword = process.env.ADMIN_PASSWORD || "admin123"; const adminPassword = process.env.ADMIN_PASSWORD || "admin123";
const adminName = envValue("ADMIN_NAME", "Admin"); 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 siteUrl = process.env.NEXTAUTH_URL?.trim() || null;
const subscriptionUrl = process.env.SUBSCRIPTION_URL?.trim() || null; const subscriptionUrl = process.env.SUBSCRIPTION_URL?.trim() || null;
const hashedPassword = await bcrypt.hash(adminPassword, 12); const hashedPassword = await bcrypt.hash(adminPassword, 12);

View File

@@ -275,7 +275,7 @@ load_existing_env() {
APP_PORT="${APP_PORT:-3000}" APP_PORT="${APP_PORT:-3000}"
PUBLIC_URL="${NEXTAUTH_URL:-}" PUBLIC_URL="${NEXTAUTH_URL:-}"
SUBSCRIPTION_PUBLIC_URL="${SUBSCRIPTION_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_EMAIL="${ADMIN_EMAIL:-admin@jboard.local}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
ADMIN_NAME="${ADMIN_NAME:-Admin}" ADMIN_NAME="${ADMIN_NAME:-Admin}"
@@ -338,7 +338,7 @@ configure_env() {
ip="$(server_ip)" ip="$(server_ip)"
default_url="http://${ip}:3000" 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="$(prompt_value "网站访问地址" "$default_url" "这里请填写你准备反向代理到本机 3000 端口的面板域名,例如 https://panel.example.com。没有域名时可先回车用 IP:3000 测试。")"
PUBLIC_URL="$(normalize_url "$PUBLIC_URL")" PUBLIC_URL="$(normalize_url "$PUBLIC_URL")"
SUBSCRIPTION_PUBLIC_URL="$(prompt_value "订阅访问地址" "$PUBLIC_URL" "用于生成客户端订阅链接。可以和网站地址相同,也可以填单独反代到本面板的订阅域名,例如 https://sub.example.com。")" 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 { revalidatePath } from "next/cache";
import { confirmPendingOrder } from "@/services/payment/process"; import { confirmPendingOrder } from "@/services/payment/process";
import { actorFromSession, recordAuditLog } from "@/services/audit"; import { actorFromSession, recordAuditLog } from "@/services/audit";
import { orderReviewStatusLabels } from "@/lib/domain-labels";
export async function confirmOrder(orderId: string) { export async function confirmOrder(orderId: string) {
const session = await requireAdmin(); const session = await requireAdmin();
@@ -66,7 +67,7 @@ export async function updateOrderReview(
targetType: "Order", targetType: "Order",
targetId: order.id, targetId: order.id,
targetLabel: order.id, targetLabel: order.id,
message: `将订单 ${order.id} 标记为 ${reviewStatus}`, message: `将订单 ${order.id} 标记为${orderReviewStatusLabels[reviewStatus]}`,
}); });
revalidatePath("/admin/orders"); revalidatePath("/admin/orders");

View File

@@ -11,8 +11,19 @@ import {
preparePaymentConfigForStorage, preparePaymentConfigForStorage,
} from "@/services/payment/catalog"; } from "@/services/payment/catalog";
import { actorFromSession, recordAuditLog } from "@/services/audit"; import { actorFromSession, recordAuditLog } from "@/services/audit";
import { getErrorMessage } from "@/lib/errors";
import { z } from "zod"; 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( export async function savePaymentConfig(
provider: string, provider: string,
config: Record<string, string>, config: Record<string, string>,
@@ -60,3 +71,56 @@ export async function savePaymentConfig(
}); });
revalidatePath("/admin/payments"); 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} isEmpty={announcements.length === 0}
emptyTitle="暂无公告或消息" emptyTitle="暂无公告或消息"
emptyDescription="发布公告后,会显示展示范围、时间窗口和启用状态。" emptyDescription="发布公告后,会显示展示范围、时间窗口和启用状态。"
mobileCards={announcements.map((announcement) => (
<article key={announcement.id} className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">
<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]"> <DataTable aria-label="公告列表" className="min-w-[1040px]">
<DataTableHead> <DataTableHead>

View File

@@ -25,6 +25,36 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
isEmpty={logs.length === 0} isEmpty={logs.length === 0}
emptyTitle="暂无审计日志" emptyTitle="暂无审计日志"
emptyDescription="后台关键操作发生后,会记录在这里。" emptyDescription="后台关键操作发生后,会记录在这里。"
mobileCards={logs.map((log) => (
<article key={log.id} className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">
<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]"> <DataTable aria-label="审计日志列表" className="min-w-[980px]">
<DataTableHead> <DataTableHead>

View File

@@ -16,7 +16,7 @@ const NODE_BATCH_FORM_ID = "node-batch-form";
function PanelInfoBar({ node }: { node: NodeServerRow }) { function PanelInfoBar({ node }: { node: NodeServerRow }) {
return ( 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 className="font-medium text-foreground">3x-ui</span>
<span>{node.panelUrl || "未配置面板"}</span> <span>{node.panelUrl || "未配置面板"}</span>
{node.agentToken && <span> Token: 已启用</span>} {node.agentToken && <span> Token: 已启用</span>}
@@ -74,11 +74,11 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<PanelInfoBar node={node} /> <PanelInfoBar node={node} />
{node.inbounds.length > 0 ? ( {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) => ( {node.inbounds.map((inbound) => (
<div <div
key={inbound.id} 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" /> <Waypoints className="size-3.5 shrink-0 text-primary" />
<span className="shrink-0 text-muted-foreground">{inbound.protocol} · {inbound.port}</span> <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, value: filters.status,
options: [ options: [
{ label: "全部状态", value: "" }, { label: "全部状态", value: "" },
{ label: "active", value: "active" }, { label: "已启用", value: "active" },
{ label: "inactive", value: "inactive" }, { label: "已停用", value: "inactive" },
], ],
}, },
]} ]}

View File

@@ -50,6 +50,45 @@ export function OrdersTable({ orders }: OrdersTableProps) {
</BatchActionButton> </BatchActionButton>
</BatchActionBar> </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]"> <DataTable aria-label="订单列表" className="min-w-[1180px]">
<DataTableHead> <DataTableHead>

View File

@@ -1,10 +1,10 @@
"use client"; "use client";
import { useMemo, useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Check, CreditCard, Pencil, ShieldCheck } from "lucide-react"; import { Check, CreditCard, Pencil, ShieldCheck } from "lucide-react";
import { savePaymentConfig } from "@/actions/admin/payments"; import { savePaymentConfig, setPaymentConfigEnabled } from "@/actions/admin/payments";
import { ActiveStatusBadge, StatusBadge } from "@/components/shared/status-badge"; import { StatusBadge } from "@/components/shared/status-badge";
import { BooleanToggle } from "@/components/ui/boolean-toggle"; import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -64,24 +64,6 @@ function buildInitialCheckboxValues(fields: Field[], currentConfig?: Record<stri
return values; 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({ export function PaymentConfigItem({
provider, provider,
providerName, providerName,
@@ -95,15 +77,10 @@ export function PaymentConfigItem({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [enabled, setEnabled] = useState(initialEnabled); const [enabled, setEnabled] = useState(initialEnabled);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [statusSaving, setStatusSaving] = useState(false);
const [checkboxValues, setCheckboxValues] = useState<Record<string, Set<string>>>(() => const [checkboxValues, setCheckboxValues] = useState<Record<string, Set<string>>>(() =>
buildInitialCheckboxValues(fields, currentConfig), 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 displayName = currentConfig?.displayName?.trim();
const checkboxSummaries = fields const checkboxSummaries = fields
.filter((field) => field.type === "checkboxes") .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>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (saving) return; if (saving || statusSaving) return;
const form = event.currentTarget; const form = event.currentTarget;
const formData = new FormData(form); const formData = new FormData(form);
@@ -158,7 +158,7 @@ export function PaymentConfigItem({
} }
return ( 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"> <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"> <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" /> <CreditCard className="size-4" />
@@ -169,30 +169,34 @@ export function PaymentConfigItem({
{displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>} {displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>}
</div> </div>
<p className="mt-1 line-clamp-2 text-sm leading-6 text-muted-foreground">{providerDescription}</p> <p className="mt-1 line-clamp-2 text-sm leading-6 text-muted-foreground">{providerDescription}</p>
</div> {checkboxSummaries.length > 0 && (
</div> <div className="mt-2 flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2 lg:justify-end">
<ActiveStatusBadge active={enabled} activeLabel="已启用" inactiveLabel="未启用" />
<StatusBadge tone={completeness.configured === completeness.total ? "success" : "neutral"}>
{completeness.configured}/{completeness.total}
</StatusBadge>
{secretFields.length > 0 && (
<StatusBadge tone={configuredSecretCount === secretFields.length ? "success" : "warning"}>
{configuredSecretCount}/{secretFields.length}
</StatusBadge>
)}
{checkboxSummaries.slice(0, 2).map((label) => ( {checkboxSummaries.slice(0, 2).map((label) => (
<StatusBadge key={label} tone="info">{label}</StatusBadge> <StatusBadge key={label} tone="info">{label}</StatusBadge>
))} ))}
</div> </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)}> <Dialog open={open} onOpenChange={(nextOpen) => !saving && setOpen(nextOpen)}>
<DialogTrigger render={<Button variant="outline" size="sm" className="w-full lg:w-auto" />}> <DialogTrigger render={<Button variant="outline" size="sm" className="w-full lg:w-auto" />}>
<Pencil className="size-3.5" /> <Pencil className="size-3.5" />
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-3xl"> <DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto bg-card sm:max-w-3xl">
<DialogHeader> <DialogHeader>
<div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary"> <div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<ShieldCheck className="size-4" /> <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 className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<Label className="text-sm font-semibold"></Label> <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>
<div className="w-full sm:w-56"> <div className="flex justify-start sm:justify-end">
<BooleanToggle <StatusBadge tone={enabled ? "success" : "neutral"}>
value={enabled} {enabled ? "已启用" : "已停用"}
onChange={setEnabled} </StatusBadge>
trueLabel="启用"
falseLabel="停用"
ariaLabel="支付通道状态"
disabled={saving}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -17,7 +17,7 @@ export default async function PaymentsPage() {
eyebrow="系统" eyebrow="系统"
title="支付配置" 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 }) => ( {providerConfigs.map(({ provider, config, secretConfigured }) => (
<PaymentConfigItem <PaymentConfigItem
key={provider.id} key={provider.id}

View File

@@ -161,7 +161,7 @@ export function PlanCard({ plan, activeCount, services, batchFormId }: PlanCardP
/> />
</div> </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"}> <StatusBadge tone={plan.type === "PROXY" ? "info" : "warning"}>
{plan.type === "PROXY" ? "代理套餐" : "流媒体套餐"} {plan.type === "PROXY" ? "代理套餐" : "流媒体套餐"}
</StatusBadge> </StatusBadge>

View File

@@ -31,6 +31,35 @@ export function ServicesTable({ services }: { services: StreamingServiceRow[] })
<BatchActionButton name="isActive" value="false" destructive></BatchActionButton> <BatchActionButton name="isActive" value="false" destructive></BatchActionButton>
</BatchActionBar> </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]"> <DataTable aria-label="流媒体服务列表" className="min-w-[980px]">
<DataTableHead> <DataTableHead>

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { PageHeader, PageShell } from "@/components/shared/page-shell"; import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { getAppConfig } from "@/services/app-config"; import { getAppConfig } from "@/services/app-config";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { formatDate } from "@/lib/utils";
import { SettingsForm } from "./settings-form"; import { SettingsForm } from "./settings-form";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -39,7 +40,7 @@ export default async function AdminSettingsPage() {
trafficSyncIntervalSeconds: config.trafficSyncIntervalSeconds, trafficSyncIntervalSeconds: config.trafficSyncIntervalSeconds,
logCleanupEnabled: config.logCleanupEnabled, logCleanupEnabled: config.logCleanupEnabled,
logRetentionDays: config.logRetentionDays, logRetentionDays: config.logRetentionDays,
logCleanupLastRunAt: config.logCleanupLastRunAt, logCleanupLastRunAt: config.logCleanupLastRunAt ? formatDate(config.logCleanupLastRunAt) : null,
networkRecommendationsEnabled: config.networkRecommendationsEnabled, networkRecommendationsEnabled: config.networkRecommendationsEnabled,
networkInsightsEnabled: config.networkInsightsEnabled, networkInsightsEnabled: config.networkInsightsEnabled,
subscriptionRiskEnabled: config.subscriptionRiskEnabled, subscriptionRiskEnabled: config.subscriptionRiskEnabled,

View File

@@ -2,7 +2,7 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation"; 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 { cleanupExpiredAdminLogs } from "@/actions/admin/logs";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import { BooleanToggle } from "@/components/ui/boolean-toggle"; import { BooleanToggle } from "@/components/ui/boolean-toggle";
@@ -18,7 +18,7 @@ import {
} from "@/actions/admin/settings"; } from "@/actions/admin/settings";
import { toast } from "sonner"; import { toast } from "sonner";
import { getErrorMessage } from "@/lib/errors"; import { getErrorMessage } from "@/lib/errors";
import { formatDate } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
booleanAppSettingLabels, booleanAppSettingLabels,
type BooleanAppSettingField, type BooleanAppSettingField,
@@ -42,7 +42,7 @@ interface AppConfig {
trafficSyncIntervalSeconds: number; trafficSyncIntervalSeconds: number;
logCleanupEnabled: boolean; logCleanupEnabled: boolean;
logRetentionDays: number; logRetentionDays: number;
logCleanupLastRunAt: Date | string | null; logCleanupLastRunAt: string | null;
networkRecommendationsEnabled: boolean; networkRecommendationsEnabled: boolean;
networkInsightsEnabled: boolean; networkInsightsEnabled: boolean;
subscriptionRiskEnabled: 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 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 = [ const logCleanupTargetOptions = [
{ value: "ALL", label: "全部日志" }, { value: "ALL", label: "全部日志" },
@@ -123,11 +152,15 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
const [cleaningLogs, setCleaningLogs] = useState(false); const [cleaningLogs, setCleaningLogs] = useState(false);
const [cleanupTarget, setCleanupTarget] = useState<LogCleanupTarget>("ALL"); const [cleanupTarget, setCleanupTarget] = useState<LogCleanupTarget>("ALL");
const [manualCleanupDays, setManualCleanupDays] = useState(config.logRetentionDays); 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 [toggleValues, setToggleValues] = useState<ToggleValues>(() => initialToggleValues(config));
const [pendingToggles, setPendingToggles] = useState<Partial<Record<BooleanSettingField, boolean>>>({}); const [pendingToggles, setPendingToggles] = useState<Partial<Record<BooleanSettingField, boolean>>>({});
const hasPendingToggle = Object.values(pendingToggles).some(Boolean); const hasPendingToggle = Object.values(pendingToggles).some(Boolean);
function sectionClass(value: SettingsSectionValue) {
return cn(sectionClassName, activeSection !== value && "hidden");
}
function setToggleValue(field: BooleanSettingField, value: boolean) { function setToggleValue(field: BooleanSettingField, value: boolean) {
setToggleValues((current) => ({ ...current, [field]: value })); setToggleValues((current) => ({ ...current, [field]: value }));
} }
@@ -277,19 +310,37 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
} }
return ( 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"> <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"> <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" /> <Settings2 className="size-5" />
</span> </span>
<div> <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> <p className="mt-1 text-sm leading-6 text-muted-foreground"></p>
</div> </div>
</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"> <section id="settings-basic" className={sectionClass("basic")}>
<div className="flex items-center gap-2 text-sm font-semibold"> <div className={sectionHeadingClassName}>
<Settings2 className="size-4 text-primary" /> <Settings2 className="size-4 text-primary" />
</div> </div>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
@@ -314,8 +365,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
</section> </section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3"> <section id="settings-support" className={sectionClass("support")}>
<div className="flex items-center gap-2 text-sm font-semibold"> <div className={sectionHeadingClassName}>
<LifeBuoy className="size-4 text-primary" /> <LifeBuoy className="size-4 text-primary" />
</div> </div>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
@@ -337,8 +388,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
</section> </section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3"> <section id="settings-automation" className={sectionClass("automation")}>
<div className="flex items-center gap-2 text-sm font-semibold"> <div className={sectionHeadingClassName}>
<Clock3 className="size-4 text-primary" /> <Clock3 className="size-4 text-primary" />
</div> </div>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
@@ -370,8 +421,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
</section> </section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3"> <section id="settings-logs" className={sectionClass("logs")}>
<div className="flex items-center gap-2 text-sm font-semibold"> <div className={sectionHeadingClassName}>
<Trash2 className="size-4 text-primary" /> <Trash2 className="size-4 text-primary" />
</div> </div>
<p className="text-xs leading-5 text-muted-foreground"> <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"> <div className="space-y-2">
<Label></Label> <Label></Label>
<div className="flex min-h-10 items-center rounded-lg border border-border bg-background px-3 text-sm text-muted-foreground"> <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> </div>
</div> </div>
@@ -448,8 +499,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
</section> </section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3"> <section id="settings-store" className={sectionClass("store")}>
<div className="flex items-center gap-2 text-sm font-semibold"> <div className={sectionHeadingClassName}>
<RadioTower className="size-4 text-primary" /> 线 <RadioTower className="size-4 text-primary" /> 线
</div> </div>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
@@ -470,30 +521,13 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
</section> </section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3"> <section id="settings-risk" className={sectionClass("risk")}>
<button <div className={sectionHeadingClassName}>
type="button" <ShieldAlert className="size-4 text-primary" /> 访
aria-expanded={riskSettingsOpen} </div>
aria-controls="subscription-risk-settings" <p className="text-xs leading-5 text-muted-foreground">
onClick={() => setRiskSettingsOpen((open) => !open)}
className="flex w-full items-center justify-between gap-4 rounded-md text-left outline-none transition-colors hover:text-primary focus-visible:ring-[3px] focus-visible:ring-ring/15"
>
<span className="flex min-w-0 items-start gap-2">
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-primary" />
<span className="min-w-0">
<span className="block text-sm font-semibold">访</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
访{toggleValues.subscriptionRiskEnabled ? "已开启" : "已关闭"} 访{toggleValues.subscriptionRiskEnabled ? "已开启" : "已关闭"}
</span> </p>
</span>
</span>
<span className="flex shrink-0 items-center gap-2 text-xs font-medium text-muted-foreground">
{riskSettingsOpen ? "收起" : "展开"}
<ChevronDown className={`size-4 transition-transform ${riskSettingsOpen ? "rotate-180" : ""}`} />
</span>
</button>
{riskSettingsOpen && (
<div id="subscription-risk-settings" className="space-y-4"> <div id="subscription-risk-settings" className="space-y-4">
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2"> <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 24 4 5 2 /3 /2 3 IP 180 / 60 / Agent XRAY_ACCESS_LOG_PATH Agent
</p> </p>
</div> </div>
)}
</section> </section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3"> <section id="settings-auth" className={sectionClass("auth")}>
<div className="flex items-center gap-2 text-sm font-semibold"> <div className={sectionHeadingClassName}>
<ShieldCheck className="size-4 text-primary" /> <ShieldCheck className="size-4 text-primary" />
</div> </div>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
@@ -677,8 +710,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
</section> </section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3"> <section id="settings-email" className={sectionClass("email")}>
<div className="flex items-center gap-2 text-sm font-semibold"> <div className={sectionHeadingClassName}>
<Mail className="size-4 text-primary" /> SMTP <Mail className="size-4 text-primary" /> SMTP
</div> </div>
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">
@@ -735,8 +768,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
</section> </section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3"> <section id="settings-invite" className={sectionClass("invite")}>
<div className="flex items-center gap-2 text-sm font-semibold"> <div className={sectionHeadingClassName}>
<Gift className="size-4 text-primary" /> <Gift className="size-4 text-primary" />
</div> </div>
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
@@ -770,8 +803,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</p> </p>
</section> </section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3"> <section id="settings-turnstile" className={sectionClass("turnstile")}>
<div className="flex items-center gap-2 text-sm font-semibold"> <div className={sectionHeadingClassName}>
<ShieldAlert className="size-4 text-primary" /> Cloudflare Turnstile <ShieldAlert className="size-4 text-primary" /> Cloudflare Turnstile
</div> </div>
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">
@@ -798,8 +831,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
</section> </section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3"> <section id="settings-notices" className={sectionClass("notices")}>
<div className="flex items-center gap-2 text-sm font-semibold"> <div className={sectionHeadingClassName}>
<Bell className="size-4 text-primary" /> <Bell className="size-4 text-primary" />
</div> </div>
<div className="grid gap-5 lg:grid-cols-2"> <div className="grid gap-5 lg:grid-cols-2">

View File

@@ -80,6 +80,54 @@ export function SubscriptionsTable({
</BatchActionButton> </BatchActionButton>
</BatchActionBar> </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]"> <DataTable aria-label="订阅列表" className="min-w-[1080px]">
<DataTableHead> <DataTableHead>

View File

@@ -29,6 +29,32 @@ export function AdminSupportTable({ tickets }: AdminSupportTableProps) {
isEmpty={tickets.length === 0} isEmpty={tickets.length === 0}
emptyTitle="暂无工单" emptyTitle="暂无工单"
emptyDescription="用户提交售后问题后,会显示在这里。" 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]"> <DataTable aria-label="后台工单列表" className="min-w-[860px]">
<DataTableHead> <DataTableHead>

View File

@@ -36,6 +36,55 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) {
<BatchActionButton></BatchActionButton> <BatchActionButton></BatchActionButton>
</BatchActionBar> </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]"> <DataTable aria-label="任务运行列表" className="min-w-[980px]">
<DataTableHead> <DataTableHead>

View File

@@ -41,6 +41,46 @@ export function TrafficClientsTable({ clients }: TrafficClientsTableProps) {
isEmpty={visibleClients.length === 0} isEmpty={visibleClients.length === 0}
emptyTitle="暂无流量数据" emptyTitle="暂无流量数据"
emptyDescription="客户端绑定订阅并同步流量后,会显示在这里。" 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]"> <DataTable aria-label="流量客户端列表" className="min-w-[760px]">
<DataTableHead> <DataTableHead>

View File

@@ -42,6 +42,46 @@ export function UsersTable({ users }: UsersTableProps) {
</BatchActionButton> </BatchActionButton>
</BatchActionBar> </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]"> <DataTable aria-label="用户列表" className="min-w-[980px]">
<DataTableHead> <DataTableHead>

View File

@@ -6,13 +6,14 @@ import { AdminSidebar } from "@/components/admin/sidebar";
import { AdminMobileNav } from "@/components/admin/mobile-nav"; import { AdminMobileNav } from "@/components/admin/mobile-nav";
import { AnnouncementLoader } from "@/components/announcements/announcement-loader"; import { AnnouncementLoader } from "@/components/announcements/announcement-loader";
import { PageTransition } from "@/components/shared/page-transition"; import { PageTransition } from "@/components/shared/page-transition";
import { PRODUCT_NAME } from "@/lib/product";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: "管理后台", default: "管理后台",
template: "%s | J-Board", template: `%s | ${PRODUCT_NAME}`,
}, },
description: "管理用户、订单、套餐、节点和系统配置。", description: "管理 J-Board Lite 用户、订单、套餐、节点和系统配置。",
}; };
export default async function AdminLayout({ export default async function AdminLayout({

View File

@@ -3,7 +3,7 @@ import { ResetPasswordClient } from "./reset-password-client";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "重设密码", title: "重设密码",
description: "设置新的 J-Board 账户密码。", description: "设置新的 J-Board Lite 账户密码。",
}; };
export default async function ResetPasswordPage({ export default async function ResetPasswordPage({

View File

@@ -2,11 +2,12 @@ import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getActiveSession } from "@/lib/require-auth"; import { getActiveSession } from "@/lib/require-auth";
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review"; import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
import { PRODUCT_NAME } from "@/lib/product";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: "支付中心", default: "支付中心",
template: "%s | J-Board", template: `%s | ${PRODUCT_NAME}`,
}, },
description: "选择支付方式并完成订单结算。", description: "选择支付方式并完成订单结算。",
}; };

View File

@@ -9,13 +9,14 @@ import { getUnreadNotificationCount } from "./notifications/notifications-data";
import { PageTransition } from "@/components/shared/page-transition"; import { PageTransition } from "@/components/shared/page-transition";
import { SubscriptionRiskRestrictionGate } from "@/components/user/subscription-risk-restriction-gate"; import { SubscriptionRiskRestrictionGate } from "@/components/user/subscription-risk-restriction-gate";
import { getActiveSubscriptionRiskRestriction, reasonLabel } from "@/services/subscription-risk-review"; import { getActiveSubscriptionRiskRestriction, reasonLabel } from "@/services/subscription-risk-review";
import { PRODUCT_NAME } from "@/lib/product";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: "用户中心", default: "用户中心",
template: "%s | J-Board", template: `%s | ${PRODUCT_NAME}`,
}, },
description: "管理套餐、订单、订阅和账户信息。", description: "管理 J-Board Lite 套餐、订单、订阅和账户信息。",
}; };
export default async function UserLayout({ export default async function UserLayout({

View File

@@ -132,7 +132,7 @@
} }
.animate-fade-in-up { .animate-fade-in-up {
animation: fade-in-up 300ms var(--ease-fluid) both; animation: fade-in-up 180ms var(--ease-fluid) both;
} }
@layer base { @layer base {
@@ -181,7 +181,7 @@
} }
.surface-lift { .surface-lift {
transition: box-shadow 200ms ease, border-color 200ms ease; transition: box-shadow 150ms ease, border-color 150ms ease;
} }
.surface-lift:hover { .surface-lift:hover {
@@ -198,7 +198,7 @@
} }
.text-display { .text-display {
letter-spacing: -0.035em; letter-spacing: 0;
line-height: 1; line-height: 1;
} }

View File

@@ -4,7 +4,7 @@ import { getActiveSession } from "@/lib/require-auth";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "首页", title: "首页",
description: "J-Board 首页路由,会根据身份跳转到对应工作台。", description: "J-Board Lite 首页路由,会根据身份跳转到对应工作台。",
}; };
export default async function Home() { export default async function Home() {

View File

@@ -3,7 +3,7 @@ import { VerifyEmailClient } from "./verify-email-client";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "邮箱验证", title: "邮箱验证",
description: "确认 J-Board 账户邮箱。", description: "确认 J-Board Lite 账户邮箱。",
}; };
export default async function VerifyEmailPage({ 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
export interface AdminFilterOption { export interface AdminFilterOption {
label: string; label: string;
@@ -21,10 +26,35 @@ export function AdminFilterBar({
q?: string; q?: string;
searchPlaceholder?: string; searchPlaceholder?: string;
selects?: AdminFilterSelect[]; 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 ( 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]"> <div className="min-w-0 md:min-w-[16rem] md:flex-[1_1_18rem]">
<label className="sr-only" htmlFor="admin-filter-search"> <label className="sr-only" htmlFor="admin-filter-search">
{searchPlaceholder ?? "搜索"} {searchPlaceholder ?? "搜索"}
@@ -34,7 +64,7 @@ export function AdminFilterBar({
name="q" name="q"
defaultValue={q ?? ""} defaultValue={q ?? ""}
placeholder={searchPlaceholder ?? "搜索"} placeholder={searchPlaceholder ?? "搜索"}
className="h-11" className="h-10 md:h-11"
/> />
</div> </div>
{selects.map((select) => ( {selects.map((select) => (
@@ -46,7 +76,7 @@ export function AdminFilterBar({
id={`admin-filter-${select.name}`} id={`admin-filter-${select.name}`}
name={select.name} name={select.name}
defaultValue={select.value} 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) => ( {select.options.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
@@ -56,10 +86,11 @@ export function AdminFilterBar({
</select> </select>
</div> </div>
))} ))}
<Button type="submit" className="h-11 md:flex-none"> <Button type="submit" className="h-10 md:h-11 md:flex-none">
</Button> </Button>
{children} {children && <div className="hidden md:block">{children}</div>}
</div>
</form> </form>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ export function DataTableHeaderRow({ className, ...props }: ComponentProps<"tr">
return ( return (
<tr <tr
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -26,7 +26,7 @@ export function DataTableBody({ className, ...props }: ComponentProps<"tbody">)
} }
export function DataTableRow({ className, ...props }: ComponentProps<"tr">) { 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">) { export function DataTableHeadCell({ className, ...props }: ComponentProps<"th">) {

View File

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

View File

@@ -18,14 +18,14 @@ export function MetricCard({
valueClassName, valueClassName,
}: MetricCardProps) { }: MetricCardProps) {
return ( return (
<Card className={cn("min-h-28", className)}> <Card size="sm" className={cn("min-h-24", className)}>
<CardHeader className="pb-1"> <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} {label}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <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>} {description && <p className="mt-1.5 text-xs leading-5 text-muted-foreground text-pretty">{description}</p>}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -13,9 +13,9 @@ export function MobileDrawer({ open, onOpenChange, children }: MobileDrawerProps
return ( return (
<Drawer.Root open={open} onOpenChange={onOpenChange}> <Drawer.Root open={open} onOpenChange={onOpenChange}>
<Drawer.Portal> <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.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"> <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-[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.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} {children}
</Drawer.Popup> </Drawer.Popup>
</Drawer.Viewport> </Drawer.Viewport>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { Menu } from "lucide-react"; import { Menu, X } from "lucide-react";
import { MobileDrawer } from "./mobile-drawer"; import { MobileDrawer } from "./mobile-drawer";
import { Sidebar, type SidebarGroup, type SidebarLink } from "./sidebar"; import { Sidebar, type SidebarGroup, type SidebarLink } from "./sidebar";
import { ThemeToggle } from "./theme-toggle"; import { ThemeToggle } from "./theme-toggle";
@@ -47,6 +47,17 @@ export function MobileHeader({ title, subtitle, links, groups, matchMode, collap
matchMode={matchMode} matchMode={matchMode}
collapsibleGroups={collapsibleGroups} collapsibleGroups={collapsibleGroups}
railCollapsible={false} 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)} onNavigate={() => setOpen(false)}
/> />
</MobileDrawer> </MobileDrawer>

View File

@@ -34,7 +34,7 @@ interface EmptyStateProps {
export function PageShell({ children, className }: PageShellProps) { export function PageShell({ children, className }: PageShellProps) {
return ( 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} {children}
</div> </div>
); );
@@ -103,13 +103,13 @@ export function EmptyState({
return ( return (
<div <div
className={cn( 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, className,
)} )}
> >
<div className="mx-auto flex max-w-md flex-col items-center gap-3"> <div className="mx-auto flex max-w-md flex-col items-center gap-3">
{icon && ( {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} {icon}
</div> </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 ( return (
<div ref={containerRef} className="h-64 min-w-0 overflow-hidden"> <div ref={containerRef} className="h-64 min-w-0 overflow-hidden">
{width > 0 ? ( {width > 0 ? (
<AreaChart data={data} width={width} height={256}> <AreaChart data={chartData} width={width} height={256}>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" /> <XAxis dataKey="date" />
<YAxis unit=" GB" width={60} /> <YAxis
width={48}
tickFormatter={(value) => `${Number(value).toFixed(useMb ? 0 : 1)}`}
allowDecimals={!useMb}
/>
<Tooltip <Tooltip
labelFormatter={(label) => `日期:${label}`} labelFormatter={(label) => `日期:${label}`}
formatter={(value) => formatter={(value) =>
[`${Number(typeof value === "number" ? value : 0).toFixed(2)} GB`, "流量"] [`${Number(typeof value === "number" ? value : 0).toFixed(useMb ? 0 : 2)} ${unit}`, "流量"]
} }
/> />
<Area <Area
type="monotone" type="monotone"
dataKey="valueGb" dataKey="displayValue"
name="流量" name="流量"
stroke={color} stroke={color}
fill={color} fill={color}

View File

@@ -31,7 +31,7 @@ function DialogOverlay({
<DialogPrimitive.Backdrop <DialogPrimitive.Backdrop
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( 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 className
)} )}
{...props} {...props}
@@ -54,7 +54,7 @@ function DialogContent({
<DialogPrimitive.Popup <DialogPrimitive.Popup
data-slot="dialog-content" data-slot="dialog-content"
className={cn( 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 className
)} )}
{...props} {...props}

View File

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

View File

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

View File

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

View File

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

View File

@@ -740,7 +740,7 @@ function buildClashProxy(nodeClient: ProxyNodeContext, target: LinkTarget, clien
function dedupeProxyNames(proxies: JsonRecord[]) { function dedupeProxyNames(proxies: JsonRecord[]) {
const seen = new Map<string, number>(); const seen = new Map<string, number>();
return proxies.map((proxy) => { 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; const count = seen.get(name) ?? 0;
seen.set(name, count + 1); seen.set(name, count + 1);
return count === 0 ? proxy : { ...proxy, name: `${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); .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 selectableNames = proxyNames.length > 0 ? proxyNames : ["DIRECT"];
const config = { const config = {
"mixed-port": 7890, "mixed-port": 7890,