mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: split network recommendation toggles
This commit is contained in:
@@ -786,6 +786,7 @@ model AppConfig {
|
|||||||
reminderDispatchIntervalMinutes Int @default(60)
|
reminderDispatchIntervalMinutes Int @default(60)
|
||||||
trafficSyncEnabled Boolean @default(true)
|
trafficSyncEnabled Boolean @default(true)
|
||||||
trafficSyncIntervalSeconds Int @default(60)
|
trafficSyncIntervalSeconds Int @default(60)
|
||||||
|
networkRecommendationsEnabled Boolean @default(false)
|
||||||
networkInsightsEnabled Boolean @default(false)
|
networkInsightsEnabled Boolean @default(false)
|
||||||
subscriptionRiskEnabled Boolean @default(true)
|
subscriptionRiskEnabled Boolean @default(true)
|
||||||
subscriptionRiskAutoSuspend Boolean @default(true)
|
subscriptionRiskAutoSuspend Boolean @default(true)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const settingsSchema = z.object({
|
|||||||
reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(),
|
reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(),
|
||||||
trafficSyncEnabled: z.string().optional(),
|
trafficSyncEnabled: z.string().optional(),
|
||||||
trafficSyncIntervalSeconds: z.coerce.number().int().min(10).optional(),
|
trafficSyncIntervalSeconds: z.coerce.number().int().min(10).optional(),
|
||||||
|
networkRecommendationsEnabled: z.string().optional(),
|
||||||
networkInsightsEnabled: z.string().optional(),
|
networkInsightsEnabled: z.string().optional(),
|
||||||
subscriptionRiskEnabled: z.string().optional(),
|
subscriptionRiskEnabled: z.string().optional(),
|
||||||
subscriptionRiskAutoSuspend: z.string().optional(),
|
subscriptionRiskAutoSuspend: z.string().optional(),
|
||||||
@@ -138,6 +139,10 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
|
|||||||
trafficSyncEnabled: optionalBoolean(parsed.trafficSyncEnabled, current.trafficSyncEnabled),
|
trafficSyncEnabled: optionalBoolean(parsed.trafficSyncEnabled, current.trafficSyncEnabled),
|
||||||
trafficSyncIntervalSeconds:
|
trafficSyncIntervalSeconds:
|
||||||
parsed.trafficSyncIntervalSeconds ?? current.trafficSyncIntervalSeconds,
|
parsed.trafficSyncIntervalSeconds ?? current.trafficSyncIntervalSeconds,
|
||||||
|
networkRecommendationsEnabled: optionalBoolean(
|
||||||
|
parsed.networkRecommendationsEnabled,
|
||||||
|
current.networkRecommendationsEnabled,
|
||||||
|
),
|
||||||
networkInsightsEnabled: optionalBoolean(
|
networkInsightsEnabled: optionalBoolean(
|
||||||
parsed.networkInsightsEnabled,
|
parsed.networkInsightsEnabled,
|
||||||
current.networkInsightsEnabled,
|
current.networkInsightsEnabled,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default async function AdminSettingsPage() {
|
|||||||
reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes,
|
reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes,
|
||||||
trafficSyncEnabled: config.trafficSyncEnabled,
|
trafficSyncEnabled: config.trafficSyncEnabled,
|
||||||
trafficSyncIntervalSeconds: config.trafficSyncIntervalSeconds,
|
trafficSyncIntervalSeconds: config.trafficSyncIntervalSeconds,
|
||||||
|
networkRecommendationsEnabled: config.networkRecommendationsEnabled,
|
||||||
networkInsightsEnabled: config.networkInsightsEnabled,
|
networkInsightsEnabled: config.networkInsightsEnabled,
|
||||||
subscriptionRiskEnabled: config.subscriptionRiskEnabled,
|
subscriptionRiskEnabled: config.subscriptionRiskEnabled,
|
||||||
subscriptionRiskAutoSuspend: config.subscriptionRiskAutoSuspend,
|
subscriptionRiskAutoSuspend: config.subscriptionRiskAutoSuspend,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ interface AppConfig {
|
|||||||
reminderDispatchIntervalMinutes: number;
|
reminderDispatchIntervalMinutes: number;
|
||||||
trafficSyncEnabled: boolean;
|
trafficSyncEnabled: boolean;
|
||||||
trafficSyncIntervalSeconds: number;
|
trafficSyncIntervalSeconds: number;
|
||||||
|
networkRecommendationsEnabled: boolean;
|
||||||
networkInsightsEnabled: boolean;
|
networkInsightsEnabled: boolean;
|
||||||
subscriptionRiskEnabled: boolean;
|
subscriptionRiskEnabled: boolean;
|
||||||
subscriptionRiskAutoSuspend: boolean;
|
subscriptionRiskAutoSuspend: boolean;
|
||||||
@@ -252,7 +253,22 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="networkInsightsEnabled">三网推荐与线路体验</Label>
|
<Label htmlFor="networkRecommendationsEnabled">三网推荐</Label>
|
||||||
|
<select
|
||||||
|
id="networkRecommendationsEnabled"
|
||||||
|
name="networkRecommendationsEnabled"
|
||||||
|
defaultValue={String(config.networkRecommendationsEnabled)}
|
||||||
|
className={selectClassName}
|
||||||
|
>
|
||||||
|
<option value="false">关闭</option>
|
||||||
|
<option value="true">开启</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
|
开启后,商城展示电信、联通、移动当前最低延迟推荐;点击推荐会直接打开对应套餐详情。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="networkInsightsEnabled">线路体验</Label>
|
||||||
<select
|
<select
|
||||||
id="networkInsightsEnabled"
|
id="networkInsightsEnabled"
|
||||||
name="networkInsightsEnabled"
|
name="networkInsightsEnabled"
|
||||||
@@ -263,7 +279,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
|||||||
<option value="true">开启</option>
|
<option value="true">开启</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
开启后,用户侧商城展示三网推荐、节点延迟、趋势和访问路径;关闭后只保留购买所需的线路入口选择。
|
开启后,套餐详情展示节点延迟、趋势和访问路径;关闭后只保留购买所需的线路入口选择。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,14 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default async function StorePage() {
|
export default async function StorePage() {
|
||||||
const session = await getActiveSession();
|
const session = await getActiveSession();
|
||||||
const { plans, availabilityMap, pendingOrder, networkInsightsEnabled, latencyRecommendations } = await getStorePageData(session?.user.id);
|
const {
|
||||||
|
plans,
|
||||||
|
availabilityMap,
|
||||||
|
pendingOrder,
|
||||||
|
networkRecommendationsEnabled,
|
||||||
|
networkInsightsEnabled,
|
||||||
|
latencyRecommendations,
|
||||||
|
} = await getStorePageData(session?.user.id);
|
||||||
const proxyPlans = getProxyPlans(plans);
|
const proxyPlans = getProxyPlans(plans);
|
||||||
const streamingPlans = getStreamingPlans(plans);
|
const streamingPlans = getStreamingPlans(plans);
|
||||||
const proxyCards = sortPlansForDisplay(proxyPlans.map((plan) => toProxyPlanCard(plan, availabilityMap.get(plan.id))));
|
const proxyCards = sortPlansForDisplay(proxyPlans.map((plan) => toProxyPlanCard(plan, availabilityMap.get(plan.id))));
|
||||||
@@ -77,7 +84,7 @@ export default async function StorePage() {
|
|||||||
|
|
||||||
<PendingOrderBanner order={pendingOrder} />
|
<PendingOrderBanner order={pendingOrder} />
|
||||||
|
|
||||||
{networkInsightsEnabled && proxyCards.length > 0 && (
|
{networkRecommendationsEnabled && proxyCards.length > 0 && (
|
||||||
<StoreLatencyRecommendations initialItems={latencyRecommendations} />
|
<StoreLatencyRecommendations initialItems={latencyRecommendations} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Gauge, Network, Search, Sparkles } from "lucide-react";
|
import { Gauge, Network, Search, Sparkles } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { StorePlanHeader } from "./plan-card-parts";
|
import { StorePlanHeader } from "./plan-card-parts";
|
||||||
import { PlanAvailabilityBadges } from "./plan-availability-badges";
|
import { PlanAvailabilityBadges } from "./plan-availability-badges";
|
||||||
import { ProxyDetailDialog } from "./proxy-detail-dialog";
|
import { ProxyDetailDialog } from "./proxy-detail-dialog";
|
||||||
|
import { OPEN_PROXY_PLAN_EVENT } from "./store-latency-recommendations";
|
||||||
import type { ProxyPlan } from "./proxy-plan-types";
|
import type { ProxyPlan } from "./proxy-plan-types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,6 +20,18 @@ export function ProxyPlanCard({ plan, networkInsightsEnabled }: Props) {
|
|||||||
const isFixedPackage = plan.pricingMode === "FIXED_PACKAGE";
|
const isFixedPackage = plan.pricingMode === "FIXED_PACKAGE";
|
||||||
const displayPrice = isFixedPackage ? (plan.fixedPrice ?? 0) : plan.pricePerGb;
|
const displayPrice = isFixedPackage ? (plan.fixedPrice ?? 0) : plan.pricePerGb;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleOpen(event: Event) {
|
||||||
|
if (!(event instanceof CustomEvent)) return;
|
||||||
|
if (event.detail?.planId === plan.id) {
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(OPEN_PROXY_PLAN_EVENT, handleOpen);
|
||||||
|
return () => window.removeEventListener(OPEN_PROXY_PLAN_EVENT, handleOpen);
|
||||||
|
}, [plan.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<article
|
<article
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export async function getStorePageData(userId?: string) {
|
|||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
]);
|
]);
|
||||||
const latencyRecommendations = config.networkInsightsEnabled
|
const latencyRecommendations = config.networkRecommendationsEnabled
|
||||||
? await getLatencyRecommendations()
|
? await getLatencyRecommendations()
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ export async function getStorePageData(userId?: string) {
|
|||||||
return {
|
return {
|
||||||
plans,
|
plans,
|
||||||
availabilityMap,
|
availabilityMap,
|
||||||
|
networkRecommendationsEnabled: config.networkRecommendationsEnabled,
|
||||||
networkInsightsEnabled: config.networkInsightsEnabled,
|
networkInsightsEnabled: config.networkInsightsEnabled,
|
||||||
latencyRecommendations,
|
latencyRecommendations,
|
||||||
pendingOrder: pendingOrder
|
pendingOrder: pendingOrder
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Activity, Clock3, RadioTower, RefreshCw, Sparkles } from "lucide-react";
|
import { Activity, Clock3, RadioTower, RefreshCw, Sparkles } from "lucide-react";
|
||||||
import { fetchJson } from "@/lib/fetch-json";
|
import { fetchJson } from "@/lib/fetch-json";
|
||||||
@@ -18,6 +17,7 @@ interface RecommendationPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
export const OPEN_PROXY_PLAN_EVENT = "jboard:open-proxy-plan";
|
||||||
|
|
||||||
function formatTime(value: string | null) {
|
function formatTime(value: string | null) {
|
||||||
if (!value) return "等待刷新";
|
if (!value) return "等待刷新";
|
||||||
@@ -34,6 +34,14 @@ function getLatencyTone(latencyMs?: number) {
|
|||||||
return "border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
return "border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRecommendedPlan(planId: string) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(OPEN_PROXY_PLAN_EVENT, {
|
||||||
|
detail: { planId },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StoreLatencyRecommendations({
|
export function StoreLatencyRecommendations({
|
||||||
initialItems,
|
initialItems,
|
||||||
}: {
|
}: {
|
||||||
@@ -96,10 +104,16 @@ export function StoreLatencyRecommendations({
|
|||||||
{RECOMMENDATION_CARRIERS.map((carrier) => {
|
{RECOMMENDATION_CARRIERS.map((carrier) => {
|
||||||
const item = itemMap.get(carrier);
|
const item = itemMap.get(carrier);
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={carrier}
|
key={carrier}
|
||||||
|
type="button"
|
||||||
|
disabled={!item}
|
||||||
|
onClick={() => {
|
||||||
|
if (item) openRecommendedPlan(item.planId);
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border p-4 transition-colors duration-200",
|
"rounded-xl border p-4 text-left transition-colors duration-200 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20 disabled:cursor-default",
|
||||||
|
item && "hover:border-primary/25 hover:bg-primary/7",
|
||||||
getLatencyTone(item?.latencyMs),
|
getLatencyTone(item?.latencyMs),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -120,17 +134,16 @@ export function StoreLatencyRecommendations({
|
|||||||
<p className="text-lg font-semibold tracking-[-0.04em] text-foreground">{item.nodeName}</p>
|
<p className="text-lg font-semibold tracking-[-0.04em] text-foreground">{item.nodeName}</p>
|
||||||
<p className="mt-1 truncate text-xs text-muted-foreground">{item.planName}</p>
|
<p className="mt-1 truncate text-xs text-muted-foreground">{item.planName}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<span
|
||||||
href={`#plan-${item.planId}`}
|
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-semibold text-primary hover:underline"
|
className="inline-flex items-center gap-1.5 text-xs font-semibold text-primary hover:underline"
|
||||||
>
|
>
|
||||||
<Activity className="size-3.5" /> 查看套餐
|
<Activity className="size-3.5" /> 查看详情与购买
|
||||||
</Link>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-4 text-sm leading-6 text-muted-foreground">正在采集这个运营商的延迟数据。</p>
|
<p className="mt-4 text-sm leading-6 text-muted-foreground">正在采集这个运营商的延迟数据。</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export async function POST(req: Request) {
|
|||||||
const { nodeId } = auth;
|
const { nodeId } = auth;
|
||||||
const config = await getAppConfig();
|
const config = await getAppConfig();
|
||||||
|
|
||||||
if (!config.networkInsightsEnabled) {
|
if (!config.networkRecommendationsEnabled && !config.networkInsightsEnabled) {
|
||||||
return NextResponse.json({ ok: true, skipped: true });
|
return NextResponse.json({ ok: true, skipped: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +54,11 @@ export async function POST(req: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.nodeLatencyLog.create({
|
if (config.networkInsightsEnabled) {
|
||||||
data: { nodeId, carrier: entry.carrier, latencyMs },
|
await prisma.nodeLatencyLog.create({
|
||||||
});
|
data: { nodeId, carrier: entry.carrier, latencyMs },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getAppConfig } from "@/services/app-config";
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const config = await getAppConfig();
|
const config = await getAppConfig();
|
||||||
if (!config.networkInsightsEnabled) {
|
if (!config.networkRecommendationsEnabled) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
items: [],
|
items: [],
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
|||||||
Reference in New Issue
Block a user