@@ -1630,6 +1657,10 @@ function ScanTerminalScreen() {
},
[cityFallbackRows, terminalData?.rows],
);
+ const isTrialTerminalAccess = Boolean(
+ isPro &&
+ String(proAccess.subscriptionPlanCode || "").toLowerCase().includes("signup_trial"),
+ );
const fallbackFetchedRef = useRef(false);
useEffect(() => {
@@ -1743,6 +1774,10 @@ function ScanTerminalScreen() {
selectedRow={selectedRow}
setSelectedRow={handleSelectRow}
toggleLocale={toggleLocale}
+ isTrialTerminalAccess={isTrialTerminalAccess}
+ trialSubscriptionExpiresAt={
+ proAccess.subscriptionTotalExpiresAt || proAccess.subscriptionExpiresAt
+ }
userLocalTime={userLocalTime}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
diff --git a/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx b/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx
index 3c5cd7df..d03e3f76 100644
--- a/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx
+++ b/frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx
@@ -7,7 +7,7 @@ const ACCESS_TERM = {
signInToContinue: { en: "Sign in to continue", zh: "请先登录" },
proAccessRequired: { en: "Pro subscription required", zh: "需要开通 Pro" },
month: { en: "/ 30 days", zh: "/ 30 天" },
- subscribeNow: { en: "View Pro plans", zh: "查看订阅方案" },
+ subscribeNow: { en: "Subscribe & Activate", zh: "立即订阅并激活" },
backToProduct: { en: "Back to product overview", zh: "返回产品介绍页" },
} as const;
@@ -77,7 +77,7 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
diff --git a/frontend/components/dashboard/scan-terminal/__tests__/trialUpgradeNudge.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/trialUpgradeNudge.test.ts
new file mode 100644
index 00000000..3a46b591
--- /dev/null
+++ b/frontend/components/dashboard/scan-terminal/__tests__/trialUpgradeNudge.test.ts
@@ -0,0 +1,26 @@
+import fs from "node:fs";
+import path from "node:path";
+
+function assert(condition: unknown, message: string) {
+ if (!condition) throw new Error(message);
+}
+
+export function runTests() {
+ const dashboardSource = fs.readFileSync(
+ path.join(process.cwd(), "components", "dashboard", "ScanTerminalDashboard.tsx"),
+ "utf8",
+ );
+
+ assert(
+ dashboardSource.includes("isTrialTerminalAccess") &&
+ dashboardSource.includes("trialUpgradeLabel") &&
+ dashboardSource.includes('href="/account?checkout=1"'),
+ "terminal header must show a non-blocking trial countdown with a direct Pro upgrade entry",
+ );
+
+ assert(
+ dashboardSource.includes("subscriptionPlanCode") &&
+ dashboardSource.includes("signup_trial"),
+ "terminal trial upgrade nudge must be driven by the active trial subscription plan, not a generic subscribed state",
+ );
+}
diff --git a/frontend/components/ops/__tests__/opsConversionKpi.test.ts b/frontend/components/ops/__tests__/opsConversionKpi.test.ts
new file mode 100644
index 00000000..ff9c533d
--- /dev/null
+++ b/frontend/components/ops/__tests__/opsConversionKpi.test.ts
@@ -0,0 +1,25 @@
+import { getOpsPaidConversionKpi } from "@/lib/ops-conversion";
+
+function assert(condition: unknown, message: string) {
+ if (!condition) throw new Error(message);
+}
+
+export function runTests() {
+ const trialKpi = getOpsPaidConversionKpi([
+ { key: "landing_view", label: "访问落地页", count: 909, uniqueActors: 500 },
+ { key: "trial_created", label: "试用开通", count: 24, uniqueActors: 20 },
+ { key: "payment_success", label: "支付成功", count: 11, uniqueActors: 10 },
+ ]);
+
+ assert(trialKpi.rateLabel === "50.0%", "ops overview paid conversion should use trial unique actors before landing sessions");
+ assert(trialKpi.subLabel === "试用 20 → 10 · 访客 2.0%", "ops overview paid conversion should keep visitor conversion as secondary context");
+
+ const signupKpi = getOpsPaidConversionKpi([
+ { key: "landing_view", label: "访问落地页", count: 100, uniqueActors: 80 },
+ { key: "signup_success", label: "注册成功", count: 8, uniqueActors: 8 },
+ { key: "payment_success", label: "支付成功", count: 2, uniqueActors: 2 },
+ ]);
+
+ assert(signupKpi.rateLabel === "25.0%", "ops overview paid conversion should fall back to signup unique actors when no trial data exists");
+ assert(signupKpi.subLabel === "注册 8 → 2 · 访客 2.5%", "ops overview paid conversion should label the active denominator");
+}
diff --git a/frontend/components/ops/overview/OverviewPageClient.tsx b/frontend/components/ops/overview/OverviewPageClient.tsx
index b52d9d6e..02ab1cde 100644
--- a/frontend/components/ops/overview/OverviewPageClient.tsx
+++ b/frontend/components/ops/overview/OverviewPageClient.tsx
@@ -7,6 +7,7 @@ import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { getOpsPaidConversionKpi } from "@/lib/ops-conversion";
import { opsApi } from "@/lib/ops-api";
import type { MembershipEntry, MembershipsPayload, SystemStatusPayload } from "@/types/ops";
@@ -76,10 +77,9 @@ export function OverviewPageClient() {
const paid = memberships.filter((m) => !m.is_trial).length;
const trials = memberships.filter((m) => m.is_trial).length;
const steps = funnel?.steps ?? [];
- const totalUsers = steps[0]?.count ?? 0;
const stepByKey = Object.fromEntries(steps.map((step) => [step.key || step.label, step]));
const payingUsers = stepByKey.payment_success?.count ?? 0;
- const convRate = totalUsers > 0 ? ((payingUsers / totalUsers) * 100).toFixed(1) : "—";
+ const paidConversion = getOpsPaidConversionKpi(steps);
const cache = status?.cache;
const cacheAnalysis = cache?.analysis;
const td = status?.training_data;
@@ -133,7 +133,7 @@ export function OverviewPageClient() {
0 ? `+${trials} 体验` : undefined} />
-
+
diff --git a/frontend/lib/ops-conversion.ts b/frontend/lib/ops-conversion.ts
new file mode 100644
index 00000000..88fb2f0b
--- /dev/null
+++ b/frontend/lib/ops-conversion.ts
@@ -0,0 +1,62 @@
+type OpsFunnelStep = {
+ key?: string;
+ label?: string;
+ count?: number;
+ uniqueActors?: number;
+};
+
+type ConversionBase = {
+ key: string;
+ label: string;
+};
+
+const PAID_CONVERSION_BASES: ConversionBase[] = [
+ { key: "trial_created", label: "试用" },
+ { key: "signup_success", label: "注册" },
+ { key: "enter_terminal", label: "终端" },
+ { key: "landing_view", label: "访客" },
+];
+
+function finiteNonNegative(value: unknown) {
+ const numeric = Number(value);
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
+}
+
+function stepValue(step?: OpsFunnelStep) {
+ return finiteNonNegative(step?.uniqueActors) || finiteNonNegative(step?.count);
+}
+
+function formatRate(numerator: number, denominator: number) {
+ if (denominator <= 0) return "—";
+ return `${((numerator / denominator) * 100).toFixed(1)}%`;
+}
+
+export function getOpsPaidConversionKpi(steps: OpsFunnelStep[]) {
+ const stepByKey = Object.fromEntries(
+ steps.map((step) => [step.key || step.label || "", step]),
+ );
+ const paid = stepValue(stepByKey.payment_success);
+ const base = PAID_CONVERSION_BASES
+ .map((candidate) => ({
+ ...candidate,
+ value: stepValue(stepByKey[candidate.key]),
+ }))
+ .find((candidate) => candidate.value > 0);
+ const denominator = base?.value ?? 0;
+ const landing = stepValue(stepByKey.landing_view);
+ const visitorRate = landing > 0 ? formatRate(paid, landing) : "—";
+ const visitorContext =
+ base && base.key !== "landing_view" && visitorRate !== "—"
+ ? ` · 访客 ${visitorRate}`
+ : "";
+
+ return {
+ rateLabel: formatRate(paid, denominator),
+ subLabel: base
+ ? `${base.label} ${denominator} → ${paid}${visitorContext}`
+ : `支付 ${paid}`,
+ numerator: paid,
+ denominator,
+ denominatorKey: base?.key ?? null,
+ };
+}