From 6ee9cf285788c2a5c7666771aee632d62fee2875 Mon Sep 17 00:00:00 2001 From: JetSprow Date: Thu, 30 Apr 2026 20:49:03 +1000 Subject: [PATCH] Polish admin list UI for lite --- src/app/(admin)/admin/commerce/page.tsx | 66 +++++--- .../[id]/_components/tabs/inbounds-tab.tsx | 63 ++++--- .../nodes/_components/node-card-list.tsx | 146 ++++++++-------- src/app/(admin)/admin/nodes/node-actions.tsx | 2 +- .../admin/plans/_components/plans-list.tsx | 14 +- src/app/(admin)/admin/plans/plan-card.tsx | 158 +++++------------- src/components/shared/theme-provider.tsx | 4 +- src/components/shared/theme-toggle.tsx | 8 +- .../shared/time-theme-controller.tsx | 37 ++++ src/lib/product.ts | 2 +- src/services/email-templates.ts | 2 +- 11 files changed, 244 insertions(+), 258 deletions(-) create mode 100644 src/components/shared/time-theme-controller.tsx diff --git a/src/app/(admin)/admin/commerce/page.tsx b/src/app/(admin)/admin/commerce/page.tsx index 98d7a96..66489ab 100644 --- a/src/app/(admin)/admin/commerce/page.tsx +++ b/src/app/(admin)/admin/commerce/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; import { Gift, Sparkles } from "lucide-react"; import { createCoupon, createPromotionRule } from "@/actions/admin/commerce"; -import { DetailItem, DetailList } from "@/components/admin/detail-list"; import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge"; import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell"; import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; @@ -109,51 +108,66 @@ export default async function AdminCommercePage() {
-
+
{coupons.map((coupon) => ( -
-
-
- -
-

{coupon.name}

-

{coupon.code}

+
+
+ +
+
+

{coupon.name}

+
+

{coupon.code}

+
+
+ + {coupon.discountType === "PERCENT_OFF" ? `${Number(coupon.discountValue)}%` : `¥${Number(coupon.discountValue).toFixed(2)}`} + + {coupon.thresholdAmount == null ? "无门槛" : `满 ¥${Number(coupon.thresholdAmount).toFixed(2)}`} + {coupon.isPublic ? "公开展示" : "仅发放"} + 订单 {coupon._count.orders} · 发放 {coupon._count.grants} +
+
- - {coupon.discountType === "PERCENT_OFF" ? `${Number(coupon.discountValue)}%` : `¥${Number(coupon.discountValue).toFixed(2)}`} - {coupon.thresholdAmount == null ? "无门槛" : `满 ¥${Number(coupon.thresholdAmount).toFixed(2)}`} - {coupon.isPublic ? "公开" : "仅发放"} - 订单 {coupon._count.orders} · 发放 {coupon._count.grants} -
))} + {coupons.length === 0 && ( +

暂无优惠券

+ )}
-
+
{promotions.map((rule) => ( -
-
-
- -
-

{rule.name}

-

满 ¥{Number(rule.thresholdAmount).toFixed(2)} 减 ¥{Number(rule.discountAmount).toFixed(2)}

+
+
+ +
+
+

{rule.name}

+
+

满 ¥{Number(rule.thresholdAmount).toFixed(2)} 减 ¥{Number(rule.discountAmount).toFixed(2)}

-
-
- +
+ 减 ¥{Number(rule.discountAmount).toFixed(2)} + 门槛 ¥{Number(rule.thresholdAmount).toFixed(2)} 排序 {rule.sortOrder}
+
+ +
))} + {promotions.length === 0 && ( +

暂无满减规则

+ )}
diff --git a/src/app/(admin)/admin/nodes/[id]/_components/tabs/inbounds-tab.tsx b/src/app/(admin)/admin/nodes/[id]/_components/tabs/inbounds-tab.tsx index 66dfaa0..cb4eddc 100644 --- a/src/app/(admin)/admin/nodes/[id]/_components/tabs/inbounds-tab.tsx +++ b/src/app/(admin)/admin/nodes/[id]/_components/tabs/inbounds-tab.tsx @@ -1,7 +1,6 @@ "use client"; import { Waypoints } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/shared/page-shell"; import { InboundDeleteButton } from "../../../inbound-delete-button"; @@ -32,41 +31,35 @@ export function InboundsTab({ node }: { node: NodeDetail }) {

入站配置由 3x-ui 面板维护;本页仅展示已同步的线路,并允许调整前台展示名称。

-
+
{node.inbounds.map((inbound) => ( - - -
- - - - -
-
- {inbound.protocol} - :{inbound.port} - -
-
- -
- 客户端: {inbound.clients.length} - {inbound.streamSettings && typeof inbound.streamSettings === "object" && ( - <> - {(inbound.streamSettings as Record).network && ( - 传输: {String((inbound.streamSettings as Record).network)} - )} - {(inbound.streamSettings as Record).security && ( - 安全: {String((inbound.streamSettings as Record).security)} - )} - - )} -
-
-
+
+
+ + +
+
+ 客户端: {inbound.clients.length} + {inbound.streamSettings && typeof inbound.streamSettings === "object" && ( + <> + {(inbound.streamSettings as Record).network && ( + 传输: {String((inbound.streamSettings as Record).network)} + )} + {(inbound.streamSettings as Record).security && ( + 安全: {String((inbound.streamSettings as Record).security)} + )} + + )} +
+
+ {inbound.protocol} + :{inbound.port} + +
+
))}
diff --git a/src/app/(admin)/admin/nodes/_components/node-card-list.tsx b/src/app/(admin)/admin/nodes/_components/node-card-list.tsx index 75ceb11..832a41c 100644 --- a/src/app/(admin)/admin/nodes/_components/node-card-list.tsx +++ b/src/app/(admin)/admin/nodes/_components/node-card-list.tsx @@ -4,7 +4,6 @@ import { batchTestNodeConnections } from "@/actions/admin/nodes"; import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar"; import { EmptyState } from "@/components/shared/page-shell"; import { StatusBadge } from "@/components/shared/status-badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { getNodeStatusLabel } from "@/lib/domain-labels"; import { InboundDeleteButton } from "../inbound-delete-button"; import { InboundDisplayNameForm } from "../inbound-display-name-form"; @@ -16,7 +15,7 @@ const NODE_BATCH_FORM_ID = "node-batch-form"; function PanelInfoBar({ node }: { node: NodeServerRow }) { return ( -
+
3x-ui {node.panelUrl || "未配置面板"} {node.agentToken && 探测 Token: 已启用} @@ -26,75 +25,78 @@ function PanelInfoBar({ node }: { node: NodeServerRow }) { function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | null }) { return ( - - -
- - - - -
- +
+
+ + + + +
+
+

{node.name} - -

- {node.panelUrl || "未配置面板"} · {node._count.inbounds} 个入站 -

+

+ + {getNodeStatusLabel(node.status)} +
+

+ {node.panelUrl || "未配置面板"} · {node._count.inbounds} 个入站 +

-
- - {getNodeStatusLabel(node.status)} - - - -
- - +
+ +
- {node.inbounds.length > 0 ? ( -
- {node.inbounds.map((inbound) => ( -
- - {inbound.protocol} · {inbound.port} - - -
- ))} -
- ) : ( -

暂无已同步入站,请在 3x-ui 创建入站后点击同步

- )} - - +
+ + {node.inbounds.length > 0 ? ( +
+ {node.inbounds.map((inbound) => ( +
+ + {inbound.protocol} · {inbound.port} + + +
+ ))} +
+ ) : ( +

暂无已同步入站,请在 3x-ui 创建入站后点击同步

+ )} + +
+ + +
+
); } @@ -104,16 +106,18 @@ export function NodeCardList({ nodes, siteUrl }: { nodes: NodeServerRow[]; siteU 批量同步入站 -
+
{nodes.map((node) => ( ))} {nodes.length === 0 && ( - } - /> +
+ } + /> +
)}
diff --git a/src/app/(admin)/admin/nodes/node-actions.tsx b/src/app/(admin)/admin/nodes/node-actions.tsx index 7ac8fcf..f4da13e 100644 --- a/src/app/(admin)/admin/nodes/node-actions.tsx +++ b/src/app/(admin)/admin/nodes/node-actions.tsx @@ -27,7 +27,7 @@ interface NodeActionValue { agentToken: string | null; } -const INSTALL_SCRIPT_URL = "https://raw.githubusercontent.com/JetSprow/J-Board/lite/scripts/install-jboard-agent.sh"; +const INSTALL_SCRIPT_URL = "https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/install-jboard-agent.sh"; function shellQuote(value: string) { return `'${value.replaceAll("'", `'"'"'`)}'`; diff --git a/src/app/(admin)/admin/plans/_components/plans-list.tsx b/src/app/(admin)/admin/plans/_components/plans-list.tsx index 58c807c..0b2ccb3 100644 --- a/src/app/(admin)/admin/plans/_components/plans-list.tsx +++ b/src/app/(admin)/admin/plans/_components/plans-list.tsx @@ -25,7 +25,7 @@ export function PlansList({ 批量彻底删除 -
+
{plans.map((plan) => ( ))} {plans.length === 0 && ( - } - /> +
+ } + /> +
)}
diff --git a/src/app/(admin)/admin/plans/plan-card.tsx b/src/app/(admin)/admin/plans/plan-card.tsx index dd1025a..da41736 100644 --- a/src/app/(admin)/admin/plans/plan-card.tsx +++ b/src/app/(admin)/admin/plans/plan-card.tsx @@ -1,7 +1,5 @@ import { Network, Tv } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge"; -import { DetailItem, DetailList } from "@/components/admin/detail-list"; import { PlanFormValue, type StreamingServiceOption, @@ -65,27 +63,15 @@ function toNumber(value: NumericLike): number | null { return value == null ? null : Number(value); } -function money(value: NumericLike): string { - return `¥${Number(value ?? 0).toFixed(2)}`; -} +function remainingStockSummary(plan: PlanListItem, activeCount: number) { + if (plan.totalLimit == null) return { value: "∞", hint: "剩余库存", empty: false }; -function renewalSummary(plan: PlanListItem) { - if (!plan.allowRenewal) return "续费关闭"; - if (plan.renewalPricingMode === "PER_DAY") { - return `${money(plan.renewalPrice)}/天 · ${plan.renewalMinDays ?? 1}-${plan.renewalMaxDays ?? plan.durationDays} 天`; - } - return `${money(plan.renewalPrice)} / ${plan.renewalDurationDays ?? plan.durationDays} 天`; -} - -function topupSummary(plan: PlanListItem) { - if (!plan.allowTrafficTopup) return "增流量关闭"; - const range = plan.maxTopupGb == null - ? `最少 ${plan.minTopupGb ?? 1} GB` - : `${plan.minTopupGb ?? 1}-${plan.maxTopupGb} GB`; - if (plan.topupPricingMode === "FIXED_AMOUNT") { - return `${money(plan.topupFixedPrice)} 固定 · ${range}`; - } - return `${money(plan.topupPricePerGb)}/GB · ${range}`; + const remaining = Math.max(0, plan.totalLimit - activeCount); + return { + value: remaining.toString(), + hint: remaining === 0 ? "已售罄" : "剩余库存", + empty: remaining === 0, + }; } function buildPlanFormValue(plan: PlanListItem): PlanFormValue { @@ -127,105 +113,49 @@ function buildPlanFormValue(plan: PlanListItem): PlanFormValue { } export function PlanCard({ plan, activeCount, services, batchFormId }: PlanCardProps) { - const remaining = plan.totalLimit == null ? null : Math.max(0, plan.totalLimit - activeCount); const planFormValue = buildPlanFormValue(plan); + const stock = remainingStockSummary(plan, activeCount); const Icon = plan.type === "PROXY" ? Network : Tv; return ( - - -
-
- - - - -
- {plan.name} -

- {plan.description || "无描述"} · 总订阅 {plan._count.subscriptions} -

-
+
+
+ + + + +
+
+

{plan.name}

+ + {plan.type === "PROXY" ? "代理" : "流媒体"} + +
-
+
-
- - {plan.type === "PROXY" ? "代理套餐" : "流媒体套餐"} - - - {plan.durationDays} 天 - - {plan.type === "PROXY" - ? plan.pricingMode === "FIXED_PACKAGE" - ? `${money(plan.fixedPrice)} / ${plan.fixedTrafficGb ?? 0}GB` - : `${money(plan.pricePerGb)}/GB` - : money(plan.price)} - -
- +
+ + {stock.value} + + {stock.hint} +
- - {plan.type === "PROXY" ? ( - - {plan.node?.name ?? "未绑定"} - - {plan.inboundOptions.length > 0 - ? plan.inboundOptions - .map((option) => `${option.inbound.protocol}:${option.inbound.port}`) - .join(" / ") - : plan.inbound - ? `${plan.inbound.protocol}:${plan.inbound.port}` - : "未绑定"} - - - {plan.pricingMode === "FIXED_PACKAGE" - ? `固定 ${plan.fixedTrafficGb ?? 0} GB · ${money(plan.fixedPrice)}` - : `自选 ${plan.minTrafficGb ?? 0}-${plan.maxTrafficGb ?? 0} GB`} - - - {plan.totalTrafficGb == null ? "未配置" : `${plan.totalTrafficGb} GB`} - - - {plan.totalLimit == null - ? "不限量" - : `${activeCount}/${plan.totalLimit}${remaining === 0 ? " (已满)" : ""}`} - {plan.perUserLimit != null ? ` · 每人限 ${plan.perUserLimit}` : ""} - - - {renewalSummary(plan)} / {topupSummary(plan)} - - - ) : ( - - {plan.streamingService?.name ?? "未绑定"} - - {plan.streamingService - ? `${plan.streamingService.usedSlots}/${plan.streamingService.maxSlots}` - : "-"} - - - {renewalSummary(plan)} - - - {plan.totalLimit == null ? "不限量" : `${activeCount}/${plan.totalLimit}`} - {plan.perUserLimit != null ? ` · 每人限 ${plan.perUserLimit}` : ""} - - - )} - - +
+ +
+
); } diff --git a/src/components/shared/theme-provider.tsx b/src/components/shared/theme-provider.tsx index 51485dd..af7a701 100644 --- a/src/components/shared/theme-provider.tsx +++ b/src/components/shared/theme-provider.tsx @@ -2,10 +2,12 @@ import { ThemeProvider as NextThemesProvider } from "next-themes"; import type { ReactNode } from "react"; +import { TimeThemeController } from "./time-theme-controller"; export function ThemeProvider({ children }: { children: ReactNode }) { return ( - + + {children} ); diff --git a/src/components/shared/theme-toggle.tsx b/src/components/shared/theme-toggle.tsx index d14710b..0009393 100644 --- a/src/components/shared/theme-toggle.tsx +++ b/src/components/shared/theme-toggle.tsx @@ -3,6 +3,7 @@ import { Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; import { cn } from "@/lib/utils"; +import { THEME_MODE_STORAGE_KEY } from "./time-theme-controller"; export function ThemeToggle({ className }: { className?: string }) { const { resolvedTheme, setTheme } = useTheme(); @@ -15,9 +16,12 @@ export function ThemeToggle({ className }: { className?: string }) { "btn-base inline-flex size-8 items-center justify-center rounded-lg border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground", className, )} - aria-label={isDark ? "切换到日间模式" : "切换到夜间模式"} + aria-label={isDark ? "手动切换到日间模式" : "手动切换到夜间模式"} title={isDark ? "日间模式" : "夜间模式"} - onClick={() => setTheme(isDark ? "light" : "dark")} + onClick={() => { + window.localStorage.setItem(THEME_MODE_STORAGE_KEY, "manual"); + setTheme(isDark ? "light" : "dark"); + }} > {isDark ? : } diff --git a/src/components/shared/time-theme-controller.tsx b/src/components/shared/time-theme-controller.tsx new file mode 100644 index 0000000..fc14fa3 --- /dev/null +++ b/src/components/shared/time-theme-controller.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useEffect } from "react"; +import { useTheme } from "next-themes"; + +export const THEME_MODE_STORAGE_KEY = "jboard:theme-mode"; +export const THEME_STORAGE_KEY = "theme"; + +export function getTimeBasedTheme(date = new Date()) { + const hour = date.getHours(); + return hour >= 19 || hour < 7 ? "dark" : "light"; +} + +export function TimeThemeController() { + const { setTheme } = useTheme(); + + useEffect(() => { + function applyTimeTheme() { + if (window.localStorage.getItem(THEME_MODE_STORAGE_KEY) === "manual") return; + setTheme(getTimeBasedTheme()); + window.localStorage.removeItem(THEME_STORAGE_KEY); + } + + applyTimeTheme(); + const intervalId = window.setInterval(applyTimeTheme, 60 * 1000); + window.addEventListener("focus", applyTimeTheme); + document.addEventListener("visibilitychange", applyTimeTheme); + + return () => { + window.clearInterval(intervalId); + window.removeEventListener("focus", applyTimeTheme); + document.removeEventListener("visibilitychange", applyTimeTheme); + }; + }, [setTheme]); + + return null; +} diff --git a/src/lib/product.ts b/src/lib/product.ts index f39b78c..c19085c 100644 --- a/src/lib/product.ts +++ b/src/lib/product.ts @@ -3,4 +3,4 @@ import packageJson from "../../package.json"; export const PRODUCT_NAME = "J-Board Lite"; export const PRODUCT_EDITION = "Lite"; export const PRODUCT_VERSION = packageJson.version; -export const PRODUCT_REPOSITORY_URL = "https://github.com/JetSprow/J-Board"; +export const PRODUCT_REPOSITORY_URL = "https://github.com/JetSprow/J-Board-Lite"; diff --git a/src/services/email-templates.ts b/src/services/email-templates.ts index 4fdd84c..12f52d0 100644 --- a/src/services/email-templates.ts +++ b/src/services/email-templates.ts @@ -124,7 +124,7 @@ export function renderSmtpTestEmail(siteName: string) { title: "SMTP 测试邮件", intro: "这是一封来自 J-Board Lite 的测试邮件。收到它说明当前 SMTP 配置可以正常发信。", actionLabel: "返回 J-Board Lite", - actionUrl: "https://github.com/JetSprow/J-Board", + actionUrl: "https://github.com/JetSprow/J-Board-Lite", note: "你可以回到后台继续配置邮箱验证、密码找回和账户邮箱变更流程。", closing: "测试完成后,无需回复这封邮件。", });