mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
release: prepare J-Board Lite 3.1.1
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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` | 登录会话密钥 | 生产环境必须使用随机长字符串。 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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。")"
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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, "更新支付开关失败") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,22 +169,26 @@ 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>
|
||||||
|
{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>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
<div className="flex items-center justify-start lg:justify-end">
|
||||||
<ActiveStatusBadge active={enabled} activeLabel="已启用" inactiveLabel="未启用" />
|
<BooleanToggle
|
||||||
<StatusBadge tone={completeness.configured === completeness.total ? "success" : "neutral"}>
|
className="w-full lg:w-40"
|
||||||
配置 {completeness.configured}/{completeness.total}
|
value={enabled}
|
||||||
</StatusBadge>
|
onChange={(value) => void handleStatusToggle(value)}
|
||||||
{secretFields.length > 0 && (
|
trueLabel="启用"
|
||||||
<StatusBadge tone={configuredSecretCount === secretFields.length ? "success" : "warning"}>
|
falseLabel="停用"
|
||||||
密钥 {configuredSecretCount}/{secretFields.length}
|
ariaLabel={`${providerName}状态`}
|
||||||
</StatusBadge>
|
disabled={saving || statusSaving}
|
||||||
)}
|
/>
|
||||||
{checkboxSummaries.slice(0, 2).map((label) => (
|
|
||||||
<StatusBadge key={label} tone="info">{label}</StatusBadge>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={(nextOpen) => !saving && setOpen(nextOpen)}>
|
<Dialog open={open} onOpenChange={(nextOpen) => !saving && setOpen(nextOpen)}>
|
||||||
@@ -192,7 +196,7 @@ export function PaymentConfigItem({
|
|||||||
<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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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="flex items-start gap-3">
|
<div className="surface-card space-y-4 rounded-xl p-4">
|
||||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
<div className="flex items-start gap-3">
|
||||||
<Settings2 className="size-5" />
|
<span className="flex size-11 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||||
</span>
|
<Settings2 className="size-5" />
|
||||||
<div>
|
</span>
|
||||||
<h3 className="text-lg font-semibold tracking-tight">全局设置</h3>
|
<div>
|
||||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">把注册策略、自动化任务和公告内容集中配置,避免页面状态割裂。</p>
|
<h3 className="text-lg font-semibold">全局设置</h3>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">把注册策略、自动化任务和公告内容集中配置,避免页面状态割裂。</p>
|
||||||
|
</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>
|
</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,179 +521,161 @@ 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)}
|
控制订阅接口限流、跨地区访问告警和自动暂停,当前{toggleValues.subscriptionRiskEnabled ? "已开启" : "已关闭"}。
|
||||||
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"
|
</p>
|
||||||
>
|
<div id="subscription-risk-settings" className="space-y-4">
|
||||||
<span className="flex min-w-0 items-start gap-2">
|
<div className="grid gap-5 md:grid-cols-3">
|
||||||
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-primary" />
|
<div className="space-y-2">
|
||||||
<span className="min-w-0">
|
<Label htmlFor="subscriptionRiskEnabled">风控总控</Label>
|
||||||
<span className="block text-sm font-semibold">订阅访问风控</span>
|
{renderImmediateToggle("subscriptionRiskEnabled", { id: "subscriptionRiskEnabled" })}
|
||||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
</div>
|
||||||
控制订阅接口限流、跨地区访问告警和自动暂停,当前{toggleValues.subscriptionRiskEnabled ? "已开启" : "已关闭"}。
|
<div className="space-y-2">
|
||||||
</span>
|
<Label htmlFor="subscriptionRiskAutoSuspend">自动暂停</Label>
|
||||||
</span>
|
{renderImmediateToggle("subscriptionRiskAutoSuspend", {
|
||||||
</span>
|
id: "subscriptionRiskAutoSuspend",
|
||||||
<span className="flex shrink-0 items-center gap-2 text-xs font-medium text-muted-foreground">
|
trueLabel: "开启自动封停",
|
||||||
{riskSettingsOpen ? "收起" : "展开"}
|
falseLabel: "只记录警告",
|
||||||
<ChevronDown className={`size-4 transition-transform ${riskSettingsOpen ? "rotate-180" : ""}`} />
|
ariaLabel: "自动暂停",
|
||||||
</span>
|
})}
|
||||||
</button>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
{riskSettingsOpen && (
|
<Label htmlFor="subscriptionRiskWindowHours">统计窗口(小时)</Label>
|
||||||
<div id="subscription-risk-settings" className="space-y-4">
|
<Input
|
||||||
<div className="grid gap-5 md:grid-cols-3">
|
id="subscriptionRiskWindowHours"
|
||||||
<div className="space-y-2">
|
name="subscriptionRiskWindowHours"
|
||||||
<Label htmlFor="subscriptionRiskEnabled">风控总控</Label>
|
type="number"
|
||||||
{renderImmediateToggle("subscriptionRiskEnabled", { id: "subscriptionRiskEnabled" })}
|
min={1}
|
||||||
</div>
|
max={168}
|
||||||
<div className="space-y-2">
|
defaultValue={config.subscriptionRiskWindowHours}
|
||||||
<Label htmlFor="subscriptionRiskAutoSuspend">自动暂停</Label>
|
/>
|
||||||
{renderImmediateToggle("subscriptionRiskAutoSuspend", {
|
</div>
|
||||||
id: "subscriptionRiskAutoSuspend",
|
<div className="space-y-2">
|
||||||
trueLabel: "开启自动封停",
|
<Label htmlFor="subscriptionRiskCityWarning">城市警告阈值</Label>
|
||||||
falseLabel: "只记录警告",
|
<Input
|
||||||
ariaLabel: "自动暂停",
|
id="subscriptionRiskCityWarning"
|
||||||
})}
|
name="subscriptionRiskCityWarning"
|
||||||
</div>
|
type="number"
|
||||||
<div className="space-y-2">
|
min={2}
|
||||||
<Label htmlFor="subscriptionRiskWindowHours">统计窗口(小时)</Label>
|
max={100}
|
||||||
<Input
|
defaultValue={config.subscriptionRiskCityWarning}
|
||||||
id="subscriptionRiskWindowHours"
|
/>
|
||||||
name="subscriptionRiskWindowHours"
|
</div>
|
||||||
type="number"
|
<div className="space-y-2">
|
||||||
min={1}
|
<Label htmlFor="subscriptionRiskCitySuspend">城市暂停阈值</Label>
|
||||||
max={168}
|
<Input
|
||||||
defaultValue={config.subscriptionRiskWindowHours}
|
id="subscriptionRiskCitySuspend"
|
||||||
/>
|
name="subscriptionRiskCitySuspend"
|
||||||
</div>
|
type="number"
|
||||||
<div className="space-y-2">
|
min={2}
|
||||||
<Label htmlFor="subscriptionRiskCityWarning">城市警告阈值</Label>
|
max={100}
|
||||||
<Input
|
defaultValue={config.subscriptionRiskCitySuspend}
|
||||||
id="subscriptionRiskCityWarning"
|
/>
|
||||||
name="subscriptionRiskCityWarning"
|
</div>
|
||||||
type="number"
|
<div className="space-y-2">
|
||||||
min={2}
|
<Label htmlFor="subscriptionRiskRegionWarning">省/地区警告阈值</Label>
|
||||||
max={100}
|
<Input
|
||||||
defaultValue={config.subscriptionRiskCityWarning}
|
id="subscriptionRiskRegionWarning"
|
||||||
/>
|
name="subscriptionRiskRegionWarning"
|
||||||
</div>
|
type="number"
|
||||||
<div className="space-y-2">
|
min={2}
|
||||||
<Label htmlFor="subscriptionRiskCitySuspend">城市暂停阈值</Label>
|
max={100}
|
||||||
<Input
|
defaultValue={config.subscriptionRiskRegionWarning}
|
||||||
id="subscriptionRiskCitySuspend"
|
/>
|
||||||
name="subscriptionRiskCitySuspend"
|
</div>
|
||||||
type="number"
|
<div className="space-y-2">
|
||||||
min={2}
|
<Label htmlFor="subscriptionRiskRegionSuspend">省/地区暂停阈值</Label>
|
||||||
max={100}
|
<Input
|
||||||
defaultValue={config.subscriptionRiskCitySuspend}
|
id="subscriptionRiskRegionSuspend"
|
||||||
/>
|
name="subscriptionRiskRegionSuspend"
|
||||||
</div>
|
type="number"
|
||||||
<div className="space-y-2">
|
min={2}
|
||||||
<Label htmlFor="subscriptionRiskRegionWarning">省/地区警告阈值</Label>
|
max={100}
|
||||||
<Input
|
defaultValue={config.subscriptionRiskRegionSuspend}
|
||||||
id="subscriptionRiskRegionWarning"
|
/>
|
||||||
name="subscriptionRiskRegionWarning"
|
</div>
|
||||||
type="number"
|
<div className="space-y-2">
|
||||||
min={2}
|
<Label htmlFor="subscriptionRiskCountryWarning">国家警告阈值</Label>
|
||||||
max={100}
|
<Input
|
||||||
defaultValue={config.subscriptionRiskRegionWarning}
|
id="subscriptionRiskCountryWarning"
|
||||||
/>
|
name="subscriptionRiskCountryWarning"
|
||||||
</div>
|
type="number"
|
||||||
<div className="space-y-2">
|
min={2}
|
||||||
<Label htmlFor="subscriptionRiskRegionSuspend">省/地区暂停阈值</Label>
|
max={100}
|
||||||
<Input
|
defaultValue={config.subscriptionRiskCountryWarning}
|
||||||
id="subscriptionRiskRegionSuspend"
|
/>
|
||||||
name="subscriptionRiskRegionSuspend"
|
</div>
|
||||||
type="number"
|
<div className="space-y-2">
|
||||||
min={2}
|
<Label htmlFor="subscriptionRiskCountrySuspend">国家暂停阈值</Label>
|
||||||
max={100}
|
<Input
|
||||||
defaultValue={config.subscriptionRiskRegionSuspend}
|
id="subscriptionRiskCountrySuspend"
|
||||||
/>
|
name="subscriptionRiskCountrySuspend"
|
||||||
</div>
|
type="number"
|
||||||
<div className="space-y-2">
|
min={2}
|
||||||
<Label htmlFor="subscriptionRiskCountryWarning">国家警告阈值</Label>
|
max={100}
|
||||||
<Input
|
defaultValue={config.subscriptionRiskCountrySuspend}
|
||||||
id="subscriptionRiskCountryWarning"
|
/>
|
||||||
name="subscriptionRiskCountryWarning"
|
</div>
|
||||||
type="number"
|
<div className="space-y-2">
|
||||||
min={2}
|
<Label htmlFor="subscriptionRiskIpLimitPerHour">IP 限流(次/小时)</Label>
|
||||||
max={100}
|
<Input
|
||||||
defaultValue={config.subscriptionRiskCountryWarning}
|
id="subscriptionRiskIpLimitPerHour"
|
||||||
/>
|
name="subscriptionRiskIpLimitPerHour"
|
||||||
</div>
|
type="number"
|
||||||
<div className="space-y-2">
|
min={1}
|
||||||
<Label htmlFor="subscriptionRiskCountrySuspend">国家暂停阈值</Label>
|
max={100000}
|
||||||
<Input
|
defaultValue={config.subscriptionRiskIpLimitPerHour}
|
||||||
id="subscriptionRiskCountrySuspend"
|
/>
|
||||||
name="subscriptionRiskCountrySuspend"
|
</div>
|
||||||
type="number"
|
<div className="space-y-2">
|
||||||
min={2}
|
<Label htmlFor="subscriptionRiskTokenLimitPerHour">订阅限流(次/小时)</Label>
|
||||||
max={100}
|
<Input
|
||||||
defaultValue={config.subscriptionRiskCountrySuspend}
|
id="subscriptionRiskTokenLimitPerHour"
|
||||||
/>
|
name="subscriptionRiskTokenLimitPerHour"
|
||||||
</div>
|
type="number"
|
||||||
<div className="space-y-2">
|
min={1}
|
||||||
<Label htmlFor="subscriptionRiskIpLimitPerHour">IP 限流(次/小时)</Label>
|
max={100000}
|
||||||
<Input
|
defaultValue={config.subscriptionRiskTokenLimitPerHour}
|
||||||
id="subscriptionRiskIpLimitPerHour"
|
/>
|
||||||
name="subscriptionRiskIpLimitPerHour"
|
</div>
|
||||||
type="number"
|
<div className="space-y-2">
|
||||||
min={1}
|
<Label htmlFor="nodeAccessRiskEnabled">节点日志风控</Label>
|
||||||
max={100000}
|
{renderImmediateToggle("nodeAccessRiskEnabled", {
|
||||||
defaultValue={config.subscriptionRiskIpLimitPerHour}
|
id: "nodeAccessRiskEnabled",
|
||||||
/>
|
trueLabel: "接收日志",
|
||||||
</div>
|
falseLabel: "仅订阅风控",
|
||||||
<div className="space-y-2">
|
ariaLabel: "节点日志风控",
|
||||||
<Label htmlFor="subscriptionRiskTokenLimitPerHour">订阅限流(次/小时)</Label>
|
})}
|
||||||
<Input
|
</div>
|
||||||
id="subscriptionRiskTokenLimitPerHour"
|
<div className="space-y-2">
|
||||||
name="subscriptionRiskTokenLimitPerHour"
|
<Label htmlFor="nodeAccessConnectionWarning">节点连接警告阈值</Label>
|
||||||
type="number"
|
<Input id="nodeAccessConnectionWarning" name="nodeAccessConnectionWarning" type="number" min={1} max={100000} defaultValue={config.nodeAccessConnectionWarning} />
|
||||||
min={1}
|
</div>
|
||||||
max={100000}
|
<div className="space-y-2">
|
||||||
defaultValue={config.subscriptionRiskTokenLimitPerHour}
|
<Label htmlFor="nodeAccessConnectionSuspend">节点连接暂停阈值</Label>
|
||||||
/>
|
<Input id="nodeAccessConnectionSuspend" name="nodeAccessConnectionSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessConnectionSuspend} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="nodeAccessRiskEnabled">节点日志风控</Label>
|
<Label htmlFor="nodeAccessUniqueTargetWarning">不同目标警告阈值</Label>
|
||||||
{renderImmediateToggle("nodeAccessRiskEnabled", {
|
<Input id="nodeAccessUniqueTargetWarning" name="nodeAccessUniqueTargetWarning" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetWarning} />
|
||||||
id: "nodeAccessRiskEnabled",
|
</div>
|
||||||
trueLabel: "接收日志",
|
<div className="space-y-2">
|
||||||
falseLabel: "仅订阅风控",
|
<Label htmlFor="nodeAccessUniqueTargetSuspend">不同目标暂停阈值</Label>
|
||||||
ariaLabel: "节点日志风控",
|
<Input id="nodeAccessUniqueTargetSuspend" name="nodeAccessUniqueTargetSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetSuspend} />
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="nodeAccessConnectionWarning">节点连接警告阈值</Label>
|
|
||||||
<Input id="nodeAccessConnectionWarning" name="nodeAccessConnectionWarning" type="number" min={1} max={100000} defaultValue={config.nodeAccessConnectionWarning} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="nodeAccessConnectionSuspend">节点连接暂停阈值</Label>
|
|
||||||
<Input id="nodeAccessConnectionSuspend" name="nodeAccessConnectionSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessConnectionSuspend} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="nodeAccessUniqueTargetWarning">不同目标警告阈值</Label>
|
|
||||||
<Input id="nodeAccessUniqueTargetWarning" name="nodeAccessUniqueTargetWarning" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetWarning} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="nodeAccessUniqueTargetSuspend">不同目标暂停阈值</Label>
|
|
||||||
<Input id="nodeAccessUniqueTargetSuspend" name="nodeAccessUniqueTargetSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetSuspend} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
|
||||||
默认值对应原规则:24 小时内 4 城市警告、5 城市暂停;2 省/地区警告、3 省/地区暂停;2 国家警告、3 国家暂停;IP 180 次/小时,订阅 60 次/小时。节点日志风控只在 Agent 配置 XRAY_ACCESS_LOG_PATH 后生效;连接数和不同目标数按 Agent 单次聚合窗口计算。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
|
默认值对应原规则:24 小时内 4 城市警告、5 城市暂停;2 省/地区警告、3 省/地区暂停;2 国家警告、3 国家暂停;IP 180 次/小时,订阅 60 次/小时。节点日志风控只在 Agent 配置 XRAY_ACCESS_LOG_PATH 后生效;连接数和不同目标数按 Agent 单次聚合窗口计算。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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: "选择支付方式并完成订单结算。",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,45 +26,71 @@ 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="min-w-0 md:min-w-[16rem] md:flex-[1_1_18rem]">
|
<div className="flex items-center justify-between gap-3 md:hidden">
|
||||||
<label className="sr-only" htmlFor="admin-filter-search">
|
<button
|
||||||
{searchPlaceholder ?? "搜索"}
|
type="button"
|
||||||
</label>
|
onClick={() => setMobileOpen((open) => !open)}
|
||||||
<Input
|
className="btn-base btn-cream flex h-10 flex-1 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium"
|
||||||
id="admin-filter-search"
|
aria-expanded={mobileOpen}
|
||||||
name="q"
|
>
|
||||||
defaultValue={q ?? ""}
|
{mobileOpen ? <X className="size-4" /> : <SlidersHorizontal className="size-4" />}
|
||||||
placeholder={searchPlaceholder ?? "搜索"}
|
{mobileOpen ? "收起筛选" : activeFilterCount > 0 ? `筛选条件 ${activeFilterCount}` : "筛选"}
|
||||||
className="h-11"
|
</button>
|
||||||
/>
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{selects.map((select) => (
|
<div
|
||||||
<div key={select.name} className="md:min-w-[11rem] md:flex-[1_1_11rem]">
|
className={cn(
|
||||||
<label className="sr-only" htmlFor={`admin-filter-${select.name}`}>
|
"flex-col gap-3 md:flex md:flex-row md:flex-wrap md:items-end",
|
||||||
{select.options[0]?.label ?? select.name}
|
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 ?? "搜索"}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Input
|
||||||
id={`admin-filter-${select.name}`}
|
id="admin-filter-search"
|
||||||
name={select.name}
|
name="q"
|
||||||
defaultValue={select.value}
|
defaultValue={q ?? ""}
|
||||||
className="h-11 w-full px-3 text-sm outline-none"
|
placeholder={searchPlaceholder ?? "搜索"}
|
||||||
>
|
className="h-10 md:h-11"
|
||||||
{select.options.map((option) => (
|
/>
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
{selects.map((select) => (
|
||||||
<Button type="submit" className="h-11 md:flex-none">
|
<div key={select.name} className="md:min-w-[11rem] md:flex-[1_1_11rem]">
|
||||||
筛选
|
<label className="sr-only" htmlFor={`admin-filter-${select.name}`}>
|
||||||
</Button>
|
{select.options[0]?.label ?? select.name}
|
||||||
{children}
|
</label>
|
||||||
|
<select
|
||||||
|
id={`admin-filter-${select.name}`}
|
||||||
|
name={select.name}
|
||||||
|
defaultValue={select.value}
|
||||||
|
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}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="submit" className="h-10 md:h-11 md:flex-none">
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
|
{children && <div className="hidden md:block">{children}</div>}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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">) {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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: "测试完成后,无需回复这封邮件。",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user