feat: split network recommendation toggles

This commit is contained in:
JetSprow
2026-04-30 15:53:58 +10:00
parent d2ff80abc3
commit 2591402f70
10 changed files with 78 additions and 19 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>

View File

@@ -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} />
)} )}

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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,10 +54,12 @@ export async function POST(req: Request) {
}, },
}); });
if (config.networkInsightsEnabled) {
await prisma.nodeLatencyLog.create({ await prisma.nodeLatencyLog.create({
data: { nodeId, carrier: entry.carrier, latencyMs }, data: { nodeId, carrier: entry.carrier, latencyMs },
}); });
} }
}
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }

View File

@@ -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(),