mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
Polish admin list UI for lite
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Gift, Sparkles } from "lucide-react";
|
import { Gift, Sparkles } from "lucide-react";
|
||||||
import { createCoupon, createPromotionRule } from "@/actions/admin/commerce";
|
import { createCoupon, createPromotionRule } from "@/actions/admin/commerce";
|
||||||
import { DetailItem, DetailList } from "@/components/admin/detail-list";
|
|
||||||
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
|
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
|
||||||
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
|
||||||
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
|
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
|
||||||
@@ -109,51 +108,66 @@ export default async function AdminCommercePage() {
|
|||||||
<TabsContent value="manage" className="space-y-6">
|
<TabsContent value="manage" className="space-y-6">
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<SectionHeader title="优惠券" />
|
<SectionHeader title="优惠券" />
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
||||||
{coupons.map((coupon) => (
|
{coupons.map((coupon) => (
|
||||||
<article key={coupon.id} className="surface-card rounded-xl p-4">
|
<article key={coupon.id} className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,0.9fr)_auto] lg:items-center">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<div className="flex items-start gap-3">
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-300"><Gift className="size-4" /></span>
|
||||||
<span className="flex size-10 items-center justify-center rounded-[1rem] bg-amber-500/10 text-amber-700 dark:text-amber-300"><Gift className="size-4" /></span>
|
<div className="min-w-0">
|
||||||
<div>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h3 className="font-semibold">{coupon.name}</h3>
|
<h3 className="truncate font-semibold">{coupon.name}</h3>
|
||||||
<p className="mt-1 font-mono text-sm text-primary">{coupon.code}</p>
|
<ActiveStatusBadge active={coupon.isActive} activeLabel="启用中" inactiveLabel="已停用" />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-1 truncate font-mono text-sm text-primary">{coupon.code}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<StatusBadge tone="warning">
|
||||||
|
{coupon.discountType === "PERCENT_OFF" ? `${Number(coupon.discountValue)}%` : `¥${Number(coupon.discountValue).toFixed(2)}`}
|
||||||
|
</StatusBadge>
|
||||||
|
<StatusBadge>{coupon.thresholdAmount == null ? "无门槛" : `满 ¥${Number(coupon.thresholdAmount).toFixed(2)}`}</StatusBadge>
|
||||||
|
<StatusBadge>{coupon.isPublic ? "公开展示" : "仅发放"}</StatusBadge>
|
||||||
|
<StatusBadge>订单 {coupon._count.orders} · 发放 {coupon._count.grants}</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-start lg:justify-end">
|
||||||
<CommerceToggleButton kind="coupon" id={coupon.id} active={coupon.isActive} />
|
<CommerceToggleButton kind="coupon" id={coupon.id} active={coupon.isActive} />
|
||||||
</div>
|
</div>
|
||||||
<DetailList className="mt-4">
|
|
||||||
<DetailItem label="优惠">{coupon.discountType === "PERCENT_OFF" ? `${Number(coupon.discountValue)}%` : `¥${Number(coupon.discountValue).toFixed(2)}`}</DetailItem>
|
|
||||||
<DetailItem label="门槛">{coupon.thresholdAmount == null ? "无门槛" : `满 ¥${Number(coupon.thresholdAmount).toFixed(2)}`}</DetailItem>
|
|
||||||
<DetailItem label="可见性">{coupon.isPublic ? "公开" : "仅发放"}</DetailItem>
|
|
||||||
<DetailItem label="使用">订单 {coupon._count.orders} · 发放 {coupon._count.grants}</DetailItem>
|
|
||||||
</DetailList>
|
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
{coupons.length === 0 && (
|
||||||
|
<p className="px-4 py-8 text-center text-sm text-muted-foreground">暂无优惠券</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<SectionHeader title="满减规则" />
|
<SectionHeader title="满减规则" />
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
||||||
{promotions.map((rule) => (
|
{promotions.map((rule) => (
|
||||||
<article key={rule.id} className="surface-card rounded-xl p-4">
|
<article key={rule.id} className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,0.75fr)_auto] lg:items-center">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<div className="flex items-start gap-3">
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"><Sparkles className="size-4" /></span>
|
||||||
<span className="flex size-10 items-center justify-center rounded-[1rem] bg-primary/10 text-primary"><Sparkles className="size-4" /></span>
|
<div className="min-w-0">
|
||||||
<div>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h3 className="font-semibold">{rule.name}</h3>
|
<h3 className="truncate font-semibold">{rule.name}</h3>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">满 ¥{Number(rule.thresholdAmount).toFixed(2)} 减 ¥{Number(rule.discountAmount).toFixed(2)}</p>
|
<ActiveStatusBadge active={rule.isActive} activeLabel="启用中" inactiveLabel="已停用" />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">满 ¥{Number(rule.thresholdAmount).toFixed(2)} 减 ¥{Number(rule.discountAmount).toFixed(2)}</p>
|
||||||
</div>
|
</div>
|
||||||
<CommerceToggleButton kind="promotion" id={rule.id} active={rule.isActive} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<ActiveStatusBadge active={rule.isActive} activeLabel="启用中" inactiveLabel="已停用" />
|
<StatusBadge tone="info">减 ¥{Number(rule.discountAmount).toFixed(2)}</StatusBadge>
|
||||||
|
<StatusBadge>门槛 ¥{Number(rule.thresholdAmount).toFixed(2)}</StatusBadge>
|
||||||
<StatusBadge>排序 {rule.sortOrder}</StatusBadge>
|
<StatusBadge>排序 {rule.sortOrder}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-start lg:justify-end">
|
||||||
|
<CommerceToggleButton kind="promotion" id={rule.id} active={rule.isActive} />
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
{promotions.length === 0 && (
|
||||||
|
<p className="px-4 py-8 text-center text-sm text-muted-foreground">暂无满减规则</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Waypoints } from "lucide-react";
|
import { Waypoints } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { EmptyState } from "@/components/shared/page-shell";
|
import { EmptyState } from "@/components/shared/page-shell";
|
||||||
import { InboundDeleteButton } from "../../../inbound-delete-button";
|
import { InboundDeleteButton } from "../../../inbound-delete-button";
|
||||||
@@ -32,41 +31,35 @@ export function InboundsTab({ node }: { node: NodeDetail }) {
|
|||||||
<p className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
|
<p className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
|
||||||
入站配置由 3x-ui 面板维护;本页仅展示已同步的线路,并允许调整前台展示名称。
|
入站配置由 3x-ui 面板维护;本页仅展示已同步的线路,并允许调整前台展示名称。
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-3">
|
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
||||||
{node.inbounds.map((inbound) => (
|
{node.inbounds.map((inbound) => (
|
||||||
<Card key={inbound.id}>
|
<section key={inbound.id} className="grid gap-3 px-4 py-3 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,0.6fr)_auto] lg:items-center">
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-3 pb-2">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<Waypoints className="size-4 shrink-0 text-primary" />
|
||||||
<Waypoints className="size-4 shrink-0 text-primary" />
|
<InboundDisplayNameForm
|
||||||
<CardTitle className="text-sm">
|
inboundId={inbound.id}
|
||||||
<InboundDisplayNameForm
|
defaultValue={getDisplayName(inbound)}
|
||||||
inboundId={inbound.id}
|
/>
|
||||||
defaultValue={getDisplayName(inbound)}
|
</div>
|
||||||
/>
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
</CardTitle>
|
<span>客户端: {inbound.clients.length}</span>
|
||||||
</div>
|
{inbound.streamSettings && typeof inbound.streamSettings === "object" && (
|
||||||
<div className="flex items-center gap-2">
|
<>
|
||||||
<Badge variant="secondary">{inbound.protocol}</Badge>
|
{(inbound.streamSettings as Record<string, unknown>).network && (
|
||||||
<Badge variant="outline">:{inbound.port}</Badge>
|
<span>传输: {String((inbound.streamSettings as Record<string, unknown>).network)}</span>
|
||||||
<InboundDeleteButton inboundId={inbound.id} />
|
)}
|
||||||
</div>
|
{(inbound.streamSettings as Record<string, unknown>).security && (
|
||||||
</CardHeader>
|
<span>安全: {String((inbound.streamSettings as Record<string, unknown>).security)}</span>
|
||||||
<CardContent>
|
)}
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
</>
|
||||||
<span>客户端: {inbound.clients.length}</span>
|
)}
|
||||||
{inbound.streamSettings && typeof inbound.streamSettings === "object" && (
|
</div>
|
||||||
<>
|
<div className="flex items-center gap-2 lg:justify-end">
|
||||||
{(inbound.streamSettings as Record<string, unknown>).network && (
|
<Badge variant="secondary">{inbound.protocol}</Badge>
|
||||||
<span>传输: {String((inbound.streamSettings as Record<string, unknown>).network)}</span>
|
<Badge variant="outline">:{inbound.port}</Badge>
|
||||||
)}
|
<InboundDeleteButton inboundId={inbound.id} />
|
||||||
{(inbound.streamSettings as Record<string, unknown>).security && (
|
</div>
|
||||||
<span>安全: {String((inbound.streamSettings as Record<string, unknown>).security)}</span>
|
</section>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { batchTestNodeConnections } from "@/actions/admin/nodes";
|
|||||||
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
|
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
|
||||||
import { EmptyState } from "@/components/shared/page-shell";
|
import { EmptyState } from "@/components/shared/page-shell";
|
||||||
import { StatusBadge } from "@/components/shared/status-badge";
|
import { StatusBadge } from "@/components/shared/status-badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { getNodeStatusLabel } from "@/lib/domain-labels";
|
import { getNodeStatusLabel } from "@/lib/domain-labels";
|
||||||
import { InboundDeleteButton } from "../inbound-delete-button";
|
import { InboundDeleteButton } from "../inbound-delete-button";
|
||||||
import { InboundDisplayNameForm } from "../inbound-display-name-form";
|
import { InboundDisplayNameForm } from "../inbound-display-name-form";
|
||||||
@@ -16,7 +15,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 bg-muted/25 px-4 py-3 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 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>}
|
||||||
@@ -26,75 +25,78 @@ function PanelInfoBar({ node }: { node: NodeServerRow }) {
|
|||||||
|
|
||||||
function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | null }) {
|
function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | null }) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<section className="grid gap-4 px-4 py-4 xl:grid-cols-[minmax(0,1fr)_minmax(14rem,0.55fr)_minmax(20rem,0.95fr)_auto] xl:items-start">
|
||||||
<CardHeader className="flex flex-col gap-4 pb-2 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<input
|
||||||
<input
|
form={NODE_BATCH_FORM_ID}
|
||||||
form={NODE_BATCH_FORM_ID}
|
type="checkbox"
|
||||||
type="checkbox"
|
name="nodeIds"
|
||||||
name="nodeIds"
|
value={node.id}
|
||||||
value={node.id}
|
aria-label={`选择节点 ${node.name}`}
|
||||||
aria-label={`选择节点 ${node.name}`}
|
className="mt-2 size-5 rounded-lg border-border accent-primary shadow-sm"
|
||||||
className="mt-3 size-5 rounded-lg border-border accent-primary shadow-sm"
|
/>
|
||||||
/>
|
<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">
|
<Server className="size-4" />
|
||||||
<Server className="size-5" />
|
</span>
|
||||||
</span>
|
<div className="min-w-0">
|
||||||
<div className="min-w-0">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<CardTitle className="text-lg">
|
<h3 className="truncate text-base font-semibold tracking-tight">
|
||||||
<Link href={`/admin/nodes/${node.id}`} className="hover:underline">
|
<Link href={`/admin/nodes/${node.id}`} className="hover:underline">
|
||||||
{node.name}
|
{node.name}
|
||||||
</Link>
|
</Link>
|
||||||
</CardTitle>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
||||||
{node.panelUrl || "未配置面板"} · {node._count.inbounds} 个入站
|
{getNodeStatusLabel(node.status)}
|
||||||
</p>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-1 line-clamp-2 text-sm leading-6 text-muted-foreground">
|
||||||
|
{node.panelUrl || "未配置面板"} · {node._count.inbounds} 个入站
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
</div>
|
||||||
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
|
||||||
{getNodeStatusLabel(node.status)}
|
<div className="min-w-0">
|
||||||
</StatusBadge>
|
|
||||||
<NodeForm
|
|
||||||
node={{
|
|
||||||
id: node.id,
|
|
||||||
name: node.name,
|
|
||||||
panelUrl: node.panelUrl,
|
|
||||||
panelUsername: node.panelUsername,
|
|
||||||
}}
|
|
||||||
triggerLabel="编辑"
|
|
||||||
triggerVariant="outline"
|
|
||||||
/>
|
|
||||||
<NodeActions
|
|
||||||
node={{ id: node.id, name: node.name, agentToken: node.agentToken }}
|
|
||||||
siteUrl={siteUrl}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<PanelInfoBar node={node} />
|
<PanelInfoBar node={node} />
|
||||||
{node.inbounds.length > 0 ? (
|
</div>
|
||||||
<div className="grid gap-2 rounded-lg bg-muted/20 p-3">
|
|
||||||
{node.inbounds.map((inbound) => (
|
{node.inbounds.length > 0 ? (
|
||||||
<div
|
<div className="grid gap-2 rounded-lg bg-muted/20 p-3">
|
||||||
key={inbound.id}
|
{node.inbounds.map((inbound) => (
|
||||||
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"
|
<div
|
||||||
>
|
key={inbound.id}
|
||||||
<Waypoints className="size-3.5 shrink-0 text-primary" />
|
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"
|
||||||
<span className="shrink-0 text-muted-foreground">{inbound.protocol} · {inbound.port}</span>
|
>
|
||||||
<InboundDisplayNameForm
|
<Waypoints className="size-3.5 shrink-0 text-primary" />
|
||||||
inboundId={inbound.id}
|
<span className="shrink-0 text-muted-foreground">{inbound.protocol} · {inbound.port}</span>
|
||||||
defaultValue={getInboundDisplayName(inbound)}
|
<InboundDisplayNameForm
|
||||||
/>
|
inboundId={inbound.id}
|
||||||
<InboundDeleteButton inboundId={inbound.id} />
|
defaultValue={getInboundDisplayName(inbound)}
|
||||||
</div>
|
/>
|
||||||
))}
|
<InboundDeleteButton inboundId={inbound.id} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
))}
|
||||||
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">暂无已同步入站,请在 3x-ui 创建入站后点击同步</p>
|
</div>
|
||||||
)}
|
) : (
|
||||||
</CardContent>
|
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">暂无已同步入站,请在 3x-ui 创建入站后点击同步</p>
|
||||||
</Card>
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
|
||||||
|
<NodeForm
|
||||||
|
node={{
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
panelUrl: node.panelUrl,
|
||||||
|
panelUsername: node.panelUsername,
|
||||||
|
}}
|
||||||
|
triggerLabel="编辑"
|
||||||
|
triggerVariant="outline"
|
||||||
|
/>
|
||||||
|
<NodeActions
|
||||||
|
node={{ id: node.id, name: node.name, agentToken: node.agentToken }}
|
||||||
|
siteUrl={siteUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,16 +106,18 @@ export function NodeCardList({ nodes, siteUrl }: { nodes: NodeServerRow[]; siteU
|
|||||||
<BatchActionBar id={NODE_BATCH_FORM_ID} action={batchTestNodeConnections}>
|
<BatchActionBar id={NODE_BATCH_FORM_ID} action={batchTestNodeConnections}>
|
||||||
<BatchActionButton>批量同步入站</BatchActionButton>
|
<BatchActionButton>批量同步入站</BatchActionButton>
|
||||||
</BatchActionBar>
|
</BatchActionBar>
|
||||||
<div className="grid gap-5">
|
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
<NodeCard key={node.id} node={node} siteUrl={siteUrl} />
|
<NodeCard key={node.id} node={node} siteUrl={siteUrl} />
|
||||||
))}
|
))}
|
||||||
{nodes.length === 0 && (
|
{nodes.length === 0 && (
|
||||||
<EmptyState
|
<div className="p-5">
|
||||||
title="暂无节点"
|
<EmptyState
|
||||||
description="添加 3x-ui 节点后,可以同步入站并绑定到代理套餐。"
|
title="暂无节点"
|
||||||
action={<NodeForm triggerLabel="添加节点" />}
|
description="添加 3x-ui 节点后,可以同步入站并绑定到代理套餐。"
|
||||||
/>
|
action={<NodeForm triggerLabel="添加节点" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ interface NodeActionValue {
|
|||||||
agentToken: string | null;
|
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) {
|
function shellQuote(value: string) {
|
||||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function PlansList({
|
|||||||
批量彻底删除
|
批量彻底删除
|
||||||
</BatchActionButton>
|
</BatchActionButton>
|
||||||
</BatchActionBar>
|
</BatchActionBar>
|
||||||
<div className="grid gap-5">
|
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
||||||
{plans.map((plan) => (
|
{plans.map((plan) => (
|
||||||
<PlanCard
|
<PlanCard
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
@@ -36,11 +36,13 @@ export function PlansList({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{plans.length === 0 && (
|
{plans.length === 0 && (
|
||||||
<EmptyState
|
<div className="p-5">
|
||||||
title="暂无套餐"
|
<EmptyState
|
||||||
description="创建第一个套餐后,用户就可以在商店中购买。"
|
title="暂无套餐"
|
||||||
action={<PlanForm services={services} triggerLabel="创建套餐" />}
|
description="创建第一个套餐后,用户就可以在商店中购买。"
|
||||||
/>
|
action={<PlanForm services={services} triggerLabel="创建套餐" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Network, Tv } from "lucide-react";
|
import { Network, Tv } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
|
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
|
||||||
import { DetailItem, DetailList } from "@/components/admin/detail-list";
|
|
||||||
import {
|
import {
|
||||||
PlanFormValue,
|
PlanFormValue,
|
||||||
type StreamingServiceOption,
|
type StreamingServiceOption,
|
||||||
@@ -65,27 +63,15 @@ function toNumber(value: NumericLike): number | null {
|
|||||||
return value == null ? null : Number(value);
|
return value == null ? null : Number(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function money(value: NumericLike): string {
|
function remainingStockSummary(plan: PlanListItem, activeCount: number) {
|
||||||
return `¥${Number(value ?? 0).toFixed(2)}`;
|
if (plan.totalLimit == null) return { value: "∞", hint: "剩余库存", empty: false };
|
||||||
}
|
|
||||||
|
|
||||||
function renewalSummary(plan: PlanListItem) {
|
const remaining = Math.max(0, plan.totalLimit - activeCount);
|
||||||
if (!plan.allowRenewal) return "续费关闭";
|
return {
|
||||||
if (plan.renewalPricingMode === "PER_DAY") {
|
value: remaining.toString(),
|
||||||
return `${money(plan.renewalPrice)}/天 · ${plan.renewalMinDays ?? 1}-${plan.renewalMaxDays ?? plan.durationDays} 天`;
|
hint: remaining === 0 ? "已售罄" : "剩余库存",
|
||||||
}
|
empty: remaining === 0,
|
||||||
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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPlanFormValue(plan: PlanListItem): PlanFormValue {
|
function buildPlanFormValue(plan: PlanListItem): PlanFormValue {
|
||||||
@@ -127,105 +113,49 @@ function buildPlanFormValue(plan: PlanListItem): PlanFormValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PlanCard({ plan, activeCount, services, batchFormId }: PlanCardProps) {
|
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 planFormValue = buildPlanFormValue(plan);
|
||||||
|
const stock = remainingStockSummary(plan, activeCount);
|
||||||
const Icon = plan.type === "PROXY" ? Network : Tv;
|
const Icon = plan.type === "PROXY" ? Network : Tv;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center">
|
||||||
<CardHeader className="gap-4">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<input
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
form={batchFormId}
|
||||||
<input
|
type="checkbox"
|
||||||
form={batchFormId}
|
name="planIds"
|
||||||
type="checkbox"
|
value={plan.id}
|
||||||
name="planIds"
|
aria-label={`选择套餐 ${plan.name}`}
|
||||||
value={plan.id}
|
className="mt-2 size-5 rounded-lg border-border accent-primary shadow-sm"
|
||||||
aria-label={`选择套餐 ${plan.name}`}
|
/>
|
||||||
className="mt-3 size-5 rounded-lg border-border accent-primary shadow-sm"
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||||
/>
|
<Icon className="size-4" />
|
||||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
</span>
|
||||||
<Icon className="size-5" />
|
<div className="min-w-0">
|
||||||
</span>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="min-w-0 space-y-1.5">
|
<h3 className="truncate text-base font-semibold tracking-tight">{plan.name}</h3>
|
||||||
<CardTitle className="text-lg text-balance">{plan.name}</CardTitle>
|
<StatusBadge tone={plan.type === "PROXY" ? "info" : "warning"}>
|
||||||
<p className="text-sm leading-6 text-muted-foreground text-pretty">
|
{plan.type === "PROXY" ? "代理" : "流媒体"}
|
||||||
{plan.description || "无描述"} · 总订阅 {plan._count.subscriptions}
|
</StatusBadge>
|
||||||
</p>
|
<ActiveStatusBadge active={plan.isActive} activeLabel="上架中" inactiveLabel="已下架" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<PlanActions
|
|
||||||
isActive={plan.isActive}
|
|
||||||
services={services}
|
|
||||||
plan={planFormValue}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="inline-flex w-fit items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2 lg:justify-self-end">
|
||||||
<StatusBadge tone={plan.type === "PROXY" ? "info" : "warning"}>
|
<span className={`text-lg font-semibold tabular-nums ${stock.empty ? "text-destructive" : "text-foreground"}`}>
|
||||||
{plan.type === "PROXY" ? "代理套餐" : "流媒体套餐"}
|
{stock.value}
|
||||||
</StatusBadge>
|
</span>
|
||||||
<ActiveStatusBadge active={plan.isActive} activeLabel="上架中" inactiveLabel="已下架" />
|
<span className="text-xs font-medium text-muted-foreground">{stock.hint}</span>
|
||||||
<StatusBadge>{plan.durationDays} 天</StatusBadge>
|
</div>
|
||||||
<StatusBadge>
|
|
||||||
{plan.type === "PROXY"
|
|
||||||
? plan.pricingMode === "FIXED_PACKAGE"
|
|
||||||
? `${money(plan.fixedPrice)} / ${plan.fixedTrafficGb ?? 0}GB`
|
|
||||||
: `${money(plan.pricePerGb)}/GB`
|
|
||||||
: money(plan.price)}
|
|
||||||
</StatusBadge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
<div className="flex justify-start lg:justify-end">
|
||||||
{plan.type === "PROXY" ? (
|
<PlanActions
|
||||||
<DetailList>
|
isActive={plan.isActive}
|
||||||
<DetailItem label="节点">{plan.node?.name ?? "未绑定"}</DetailItem>
|
services={services}
|
||||||
<DetailItem label="入站">
|
plan={planFormValue}
|
||||||
{plan.inboundOptions.length > 0
|
/>
|
||||||
? plan.inboundOptions
|
</div>
|
||||||
.map((option) => `${option.inbound.protocol}:${option.inbound.port}`)
|
</section>
|
||||||
.join(" / ")
|
|
||||||
: plan.inbound
|
|
||||||
? `${plan.inbound.protocol}:${plan.inbound.port}`
|
|
||||||
: "未绑定"}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="售卖方式">
|
|
||||||
{plan.pricingMode === "FIXED_PACKAGE"
|
|
||||||
? `固定 ${plan.fixedTrafficGb ?? 0} GB · ${money(plan.fixedPrice)}`
|
|
||||||
: `自选 ${plan.minTrafficGb ?? 0}-${plan.maxTrafficGb ?? 0} GB`}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="流量池">
|
|
||||||
{plan.totalTrafficGb == null ? "未配置" : `${plan.totalTrafficGb} GB`}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="库存">
|
|
||||||
{plan.totalLimit == null
|
|
||||||
? "不限量"
|
|
||||||
: `${activeCount}/${plan.totalLimit}${remaining === 0 ? " (已满)" : ""}`}
|
|
||||||
{plan.perUserLimit != null ? ` · 每人限 ${plan.perUserLimit}` : ""}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="续费 / 增流量">
|
|
||||||
{renewalSummary(plan)} / {topupSummary(plan)}
|
|
||||||
</DetailItem>
|
|
||||||
</DetailList>
|
|
||||||
) : (
|
|
||||||
<DetailList>
|
|
||||||
<DetailItem label="绑定服务">{plan.streamingService?.name ?? "未绑定"}</DetailItem>
|
|
||||||
<DetailItem label="服务占用">
|
|
||||||
{plan.streamingService
|
|
||||||
? `${plan.streamingService.usedSlots}/${plan.streamingService.maxSlots}`
|
|
||||||
: "-"}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="续费">
|
|
||||||
{renewalSummary(plan)}
|
|
||||||
</DetailItem>
|
|
||||||
<DetailItem label="库存">
|
|
||||||
{plan.totalLimit == null ? "不限量" : `${activeCount}/${plan.totalLimit}`}
|
|
||||||
{plan.perUserLimit != null ? ` · 每人限 ${plan.perUserLimit}` : ""}
|
|
||||||
</DetailItem>
|
|
||||||
</DetailList>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { TimeThemeController } from "./time-theme-controller";
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
<NextThemesProvider attribute="class" defaultTheme="light" disableTransitionOnChange>
|
||||||
|
<TimeThemeController />
|
||||||
{children}
|
{children}
|
||||||
</NextThemesProvider>
|
</NextThemesProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Moon, Sun } from "lucide-react";
|
import { Moon, Sun } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { THEME_MODE_STORAGE_KEY } from "./time-theme-controller";
|
||||||
|
|
||||||
export function ThemeToggle({ className }: { className?: string }) {
|
export function ThemeToggle({ className }: { className?: string }) {
|
||||||
const { resolvedTheme, setTheme } = useTheme();
|
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",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
aria-label={isDark ? "切换到日间模式" : "切换到夜间模式"}
|
aria-label={isDark ? "手动切换到日间模式" : "手动切换到夜间模式"}
|
||||||
title={isDark ? "日间模式" : "夜间模式"}
|
title={isDark ? "日间模式" : "夜间模式"}
|
||||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
onClick={() => {
|
||||||
|
window.localStorage.setItem(THEME_MODE_STORAGE_KEY, "manual");
|
||||||
|
setTheme(isDark ? "light" : "dark");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
37
src/components/shared/time-theme-controller.tsx
Normal file
37
src/components/shared/time-theme-controller.tsx
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,4 +3,4 @@ import packageJson from "../../package.json";
|
|||||||
export const PRODUCT_NAME = "J-Board Lite";
|
export const PRODUCT_NAME = "J-Board Lite";
|
||||||
export const PRODUCT_EDITION = "Lite";
|
export const PRODUCT_EDITION = "Lite";
|
||||||
export const PRODUCT_VERSION = packageJson.version;
|
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";
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function renderSmtpTestEmail(siteName: string) {
|
|||||||
title: "SMTP 测试邮件",
|
title: "SMTP 测试邮件",
|
||||||
intro: "这是一封来自 J-Board Lite 的测试邮件。收到它说明当前 SMTP 配置可以正常发信。",
|
intro: "这是一封来自 J-Board Lite 的测试邮件。收到它说明当前 SMTP 配置可以正常发信。",
|
||||||
actionLabel: "返回 J-Board Lite",
|
actionLabel: "返回 J-Board Lite",
|
||||||
actionUrl: "https://github.com/JetSprow/J-Board",
|
actionUrl: "https://github.com/JetSprow/J-Board-Lite",
|
||||||
note: "你可以回到后台继续配置邮箱验证、密码找回和账户邮箱变更流程。",
|
note: "你可以回到后台继续配置邮箱验证、密码找回和账户邮箱变更流程。",
|
||||||
closing: "测试完成后,无需回复这封邮件。",
|
closing: "测试完成后,无需回复这封邮件。",
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user