新增终端大洲分组与移动端卡片 CSS 模块

包含分组标题行、移动端 Tab 隐藏滚动条、信号卡片样式及浅色主题适配。
This commit is contained in:
2569718930@qq.com
2026-05-25 01:55:55 +08:00
parent 0b47f4b487
commit 9a49ff3f5e
14 changed files with 237 additions and 392 deletions
-4
View File
@@ -30,10 +30,6 @@ POLYWEATHER_AUTH_ENABLED=false
# false: 登录可选,访客可浏览
POLYWEATHER_AUTH_REQUIRED=false
# 可选:分享式看板访问令牌
# 设置后,可通过 /?access_token=<token> 打开受保护看板
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
# 可选:前端 API Route 转发到后端时附带的共享令牌
# 仅当后端启用了 entitlement / 订阅校验时需要
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
+24 -18
View File
@@ -2,14 +2,18 @@
import { RefreshCw } from "lucide-react";
import { useEffect } from "react";
import { I18nProvider, useI18n } from "@/hooks/useI18n";
export default function AccountErrorPage({
function AccountErrorContent({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const { locale } = useI18n();
const isEn = locale === "en-US";
useEffect(() => {
console.error("Account page error:", error);
}, [error]);
@@ -38,7 +42,7 @@ export default function AccountErrorPage({
color: "var(--color-accent-primary, #4DA3FF)",
}}
>
{isEn ? "Account Page Error" : "账户页面出错"}
</h1>
<p
style={{
@@ -49,21 +53,9 @@ export default function AccountErrorPage({
lineHeight: 1.7,
}}
>
MetaMask
Rabby
</p>
<p
style={{
color: "var(--color-text-muted, #7D8FA3)",
fontSize: "0.8rem",
margin: 0,
maxWidth: 420,
lineHeight: 1.6,
}}
>
If this happened during payment or wallet binding, the most common cause is
conflicting wallet extensions. Try disabling other wallet extensions (e.g.
MetaMask + Rabby) and refresh.
{isEn
? "If this happened during payment or wallet binding, the most common cause is conflicting wallet extensions (e.g. MetaMask and Rabby open at the same time). Try disabling other wallet extensions and refresh."
: "如果是在支付或绑定钱包时出现此问题,常见原因是钱包插件冲突(例如同时开启了 MetaMask 和 Rabby)。请尝试关闭其他钱包插件后刷新页面重试。"}
</p>
<button
type="button"
@@ -84,8 +76,22 @@ export default function AccountErrorPage({
}}
>
<RefreshCw size={14} />
{isEn ? "Retry" : "重试"}
</button>
</div>
);
}
export default function AccountErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<I18nProvider>
<AccountErrorContent error={error} reset={reset} />
</I18nProvider>
);
}
@@ -0,0 +1,131 @@
"use client";
import Link from "next/link";
import { useI18n } from "@/hooks/useI18n";
export function EntitlementRequiredClient({ nextPath }: { nextPath: string }) {
const { locale } = useI18n();
const isEn = locale === "en-US";
const copy = {
title: isEn ? "Sign in required" : "需要登录方可访问此页面",
desc: isEn
? "This page requires you to sign in. Please sign in and try again."
: "本页面需要登录权限。请登录后重试。",
signIn: isEn ? "Sign in" : "去登录",
backHome: isEn ? "Back to Home" : "返回首页",
legacyNote: isEn ? "Legacy token mode still supported" : "传统令牌模式仍支持",
requestedPath: isEn ? "Requested path" : "请求路径",
};
return (
<main
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background:
"radial-gradient(circle at 20% 20%, #13264f 0%, #071127 45%, #040812 100%)",
color: "#d6e2ff",
padding: "24px",
}}
>
<section
style={{
width: "100%",
maxWidth: 480,
border: "1px solid rgba(68, 92, 140, 0.45)",
borderRadius: 16,
padding: 24,
background: "rgba(9, 18, 36, 0.88)",
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.35)",
textAlign: "center",
}}
>
<h1
style={{
margin: 0,
fontSize: 22,
lineHeight: 1.3,
fontWeight: 800,
}}
>
{copy.title}
</h1>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
{copy.desc}
</p>
<div
style={{
marginTop: 20,
display: "flex",
gap: 12,
justifyContent: "center",
flexWrap: "wrap",
}}
>
<Link
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
minHeight: 40,
padding: "8px 20px",
borderRadius: 12,
background: "linear-gradient(135deg, #2563EB, #4F46E5)",
color: "#fff",
fontWeight: 700,
textDecoration: "none",
fontSize: 14,
}}
>
{copy.signIn}
</Link>
<Link
href="/"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
minHeight: 40,
padding: "8px 20px",
borderRadius: 12,
border: "1px solid rgba(68, 92, 140, 0.45)",
background: "rgba(68, 92, 140, 0.2)",
color: "#d6e2ff",
fontWeight: 600,
textDecoration: "none",
fontSize: 14,
}}
>
{copy.backHome}
</Link>
</div>
<p
style={{
marginTop: 20,
fontSize: 12,
color: "#7891b5",
lineHeight: 1.5,
}}
>
{copy.legacyNote}{" "}
<code
style={{
background: "rgba(255,255,255,0.06)",
padding: "2px 6px",
borderRadius: 4,
}}
>
?access_token=&lt;your-token&gt;
</code>
<br />
<span style={{ marginTop: 4, display: "inline-block" }}>
{copy.requestedPath}: <code>{nextPath}</code>
</span>
</p>
</section>
</main>
);
}
+5 -94
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { EntitlementRequiredClient } from "./EntitlementRequiredClient";
import { I18nProvider } from "@/hooks/useI18n";
type Props = {
searchParams?: Promise<{ next?: string }>;
@@ -9,98 +10,8 @@ export default async function EntitlementRequiredPage({ searchParams }: Props) {
const nextPath = params.next || "/";
return (
<main
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background:
"radial-gradient(circle at 20% 20%, #13264f 0%, #071127 45%, #040812 100%)",
color: "#d6e2ff",
padding: "24px",
}}
>
<section
style={{
width: "100%",
maxWidth: 480,
border: "1px solid rgba(68, 92, 140, 0.45)",
borderRadius: 16,
padding: 24,
background: "rgba(9, 18, 36, 0.88)",
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.35)",
textAlign: "center",
}}
>
<h1
style={{
margin: 0,
fontSize: 22,
lineHeight: 1.3,
fontWeight: 800,
}}
>
访
</h1>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
<br />
Sign in required to access this page.
</p>
<div style={{ marginTop: 20, display: "flex", gap: 12, justifyContent: "center", flexWrap: "wrap" }}>
<Link
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
minHeight: 40,
padding: "8px 20px",
borderRadius: 12,
background: "linear-gradient(135deg, #2563EB, #4F46E5)",
color: "#fff",
fontWeight: 700,
textDecoration: "none",
fontSize: 14,
}}
>
/ Sign in
</Link>
<Link
href="/"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
minHeight: 40,
padding: "8px 20px",
borderRadius: 12,
border: "1px solid rgba(68, 92, 140, 0.45)",
background: "rgba(68, 92, 140, 0.2)",
color: "#d6e2ff",
fontWeight: 600,
textDecoration: "none",
fontSize: 14,
}}
>
/ Back to Home
</Link>
</div>
<p
style={{
marginTop: 20,
fontSize: 12,
color: "#7891b5",
lineHeight: 1.5,
}}
>
<code style={{ background: "rgba(255,255,255,0.06)", padding: "2px 6px", borderRadius: 4 }}>?access_token=&lt;your-token&gt;</code>
<br />
<span style={{ marginTop: 4, display: "inline-block" }}>
/ Requested path: <code>{nextPath}</code>
</span>
</p>
</section>
</main>
<I18nProvider>
<EntitlementRequiredClient nextPath={nextPath} />
</I18nProvider>
);
}
@@ -888,7 +888,6 @@ export function AccountCenter() {
const joinedAt = formatTime(user?.created_at, locale);
const isSubscribed = Boolean(backend?.subscription_active);
const planCode = String(backend?.subscription_plan_code || "").trim();
const isTrialPlan = /trial/i.test(planCode);
const currentExpiryRaw = String(
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
).trim();
@@ -904,7 +903,7 @@ export function AccountCenter() {
);
const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0);
const canAccessPaidTelegramGroup = Boolean(
isSubscribed && (!isTrialPlan || hasQueuedExtension),
isSubscribed,
);
const telegramBound = Number(backend?.telegram_pricing?.telegram_id || 0) > 0;
const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw;
@@ -933,7 +932,7 @@ export function AccountCenter() {
const paymentFeatureReady = paymentReadyForRecovery;
const canOpenCheckoutOverlay = Boolean(
paymentFeatureReady &&
(!isSubscribed || isTrialPlan || showExpiringSoon || showExpiredReminder),
(!isSubscribed || showExpiringSoon || showExpiredReminder),
);
const subscriptionStatusTitle = showExpiredReminder
? copy.proExpiredTitle
@@ -2237,7 +2236,7 @@ export function AccountCenter() {
>
{isSubscribed
? copy.proMember
: copy.freeTier}
: isEn ? "UNSUBSCRIBED" : "未订阅"}
</span>
</div>
<p className="mb-4 font-mono text-sm text-slate-500">
@@ -129,7 +129,6 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
? "Wallet bound. Creating order and sending payment..."
: "钱包已绑定,正在创建订单并发起支付...",
proMember: "PRO MEMBER",
freeTier: isEn ? "UNSUBSCRIBED" : "未订阅",
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期",
@@ -0,0 +1,65 @@
/* ScanTerminalContinent — continent group headers and mobile cards */
.root {
--gap-green: #16a34a;
--gap-orange: #ea580c;
--gap-slate: #475569;
--gap-gray: #94a3b8;
--gap-red: #dc2626;
}
/* Group header row */
.groupHeader {
background: #eef2f6;
border-bottom: 1px solid #cbd5e1;
}
.groupHeader:hover {
background: #e2e8f0;
}
/* Mobile: hide scrollbar on tab bar */
.mobileTabs {
scrollbar-width: none;
-ms-overflow-style: none;
}
.mobileTabs::-webkit-scrollbar {
display: none;
}
/* Mobile card */
.mobileCard {
border: 1px solid #e2e8f0;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.mobileCard:hover {
background: #f0f7ff;
}
/* Signal badge colors */
.signalActive {
color: #059669;
}
.signalWatch {
color: #d97706;
}
.signalClosed {
color: #94a3b8;
}
.signalData {
color: #dc2626;
}
/* Light theme overrides — match scan-terminal's .scan-terminal.light scope */
:global(.scan-terminal.light) .groupHeader {
background: #f8fafc;
border-bottom-color: #e2e8f0;
}
:global(.scan-terminal.light) .groupHeader:hover {
background: #f1f5f9;
}
:global(.scan-terminal.light) .mobileCard {
background: #ffffff;
border-color: #e2e8f0;
}
@@ -22,6 +22,7 @@ import scanTerminalListStyles from "./ScanTerminalList.module.css";
import scanTerminalMobileStyles from "./ScanTerminalMobile.module.css";
import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css";
import scanTerminalShellStyles from "./ScanTerminalShell.module.css";
import scanTerminalContinentStyles from "./ScanTerminalContinent.module.css";
import scanTerminalStateStyles from "./ScanTerminalState.module.css";
export const scanRootClass = clsx(
@@ -38,6 +39,7 @@ export const scanRootClass = clsx(
scanTerminalStateStyles.root,
scanTerminalOpportunityStyles.root,
scanTerminalCardStyles.root,
scanTerminalContinentStyles.root,
scanTerminalMobileStyles.root,
detailChromeStyles.root,
detailContentStyles.root,
-12
View File
@@ -1260,18 +1260,6 @@ export function DashboardStoreProvider({
});
}
const isTrialPlan = /trial/i.test(
String(proAccess.subscriptionPlanCode || ""),
);
if (
isTrialPlan &&
markAnalyticsOnce(`signup-completed:${proAccess.userId}`, "local")
) {
trackAppEvent("signup_completed", {
source: "auth_me_trial",
subscription_plan_code: proAccess.subscriptionPlanCode,
});
}
}, [
proAccess.authenticated,
proAccess.loading,
+1 -50
View File
@@ -5,8 +5,6 @@ import {
} from "@/lib/supabase/server";
import { isLocalFullAccessHost } from "@/lib/local-dev-access";
const SESSION_COOKIE = "polyweather_entitlement";
function readEnvBool(name: string, fallback: boolean) {
const raw = process.env[name];
if (raw == null) return fallback;
@@ -25,7 +23,6 @@ function isPublicPage(pathname: string) {
pathname === "/" ||
pathname.startsWith("/docs") ||
pathname.startsWith("/subscription-help") ||
pathname === "/entitlement-required" ||
pathname.startsWith("/auth/login") ||
pathname.startsWith("/auth/callback")
);
@@ -93,52 +90,6 @@ async function handleTerminalGate(request: NextRequest): Promise<NextResponse> {
return NextResponse.redirect(loginUrl);
}
function handleLegacyTokenGate(request: NextRequest) {
const requiredToken = process.env.POLYWEATHER_DASHBOARD_ACCESS_TOKEN?.trim();
if (!requiredToken) {
return NextResponse.next();
}
const { pathname, searchParams } = request.nextUrl;
if (isPublicPage(pathname) || isPublicApi(pathname)) {
return NextResponse.next();
}
const cookieToken = request.cookies.get(SESSION_COOKIE)?.value;
if (cookieToken && cookieToken === requiredToken) {
return NextResponse.next();
}
const queryToken = searchParams.get("access_token");
if (queryToken && queryToken === requiredToken) {
const cleanUrl = request.nextUrl.clone();
cleanUrl.searchParams.delete("access_token");
const response = NextResponse.redirect(cleanUrl);
response.cookies.set(SESSION_COOKIE, requiredToken, {
httpOnly: true,
sameSite: "lax",
secure: cleanUrl.protocol === "https:",
path: "/",
maxAge: 60 * 60 * 12,
});
return response;
}
if (pathname.startsWith("/api/")) {
return NextResponse.json(
{ error: "Unauthorized", detail: "Entitlement token required" },
{ status: 401 },
);
}
const deniedUrl = request.nextUrl.clone();
deniedUrl.pathname = "/entitlement-required";
deniedUrl.search = "";
deniedUrl.searchParams.set("next", pathname);
return NextResponse.redirect(deniedUrl);
}
async function handleSupabaseAuthGate(request: NextRequest) {
const { pathname } = request.nextUrl;
if (isPublicPage(pathname) || isPublicApi(pathname)) {
@@ -224,7 +175,7 @@ export async function middleware(request: NextRequest) {
}
return handleSupabaseOptionalSession(request);
}
return handleLegacyTokenGate(request);
return NextResponse.next();
}
export const config = {