From 7d03ffa30baa2b0634f4e8c11d204d968703b435 Mon Sep 17 00:00:00 2001 From: kang Date: Fri, 26 Jun 2026 18:02:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(player):=20PC=20=E4=BE=A7=E6=A0=8F?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E3=80=81=E9=A1=B6=E6=A0=8F=E4=B8=8E=E9=A1=B5?= =?UTF-8?q?=E5=86=85=E9=97=B4=E8=B7=9D=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 桌面端侧栏 + 全局顶栏(Logo 在侧栏顶、激活态对齐后台红底白字) - 移除各页重复页内顶栏,统一主区与卡片顶部留白 - 视口遮罩/弹层约束到主内容区,移动端保留底栏与顶栏 Logo --- src/app/globals.css | 22 +++- src/components/currency-switcher.tsx | 4 +- src/components/language-switcher.tsx | 4 +- src/components/layout/player-app-shell.tsx | 61 +++++---- .../layout/player-desktop-header.tsx | 45 +++++++ .../layout/player-mobile-viewport.tsx | 17 ++- .../layout/player-notification-bell.tsx | 26 +++- src/components/layout/player-panel.tsx | 67 ++-------- src/components/layout/player-sidebar.tsx | 118 ++++++++++++++++++ src/components/network-status-banner.tsx | 4 +- src/components/offline-banner.tsx | 4 +- src/components/pull-to-refresh-indicator.tsx | 6 +- src/components/server-error.tsx | 15 ++- src/components/ui/dialog.tsx | 10 +- src/components/ui/sheet.tsx | 6 +- src/components/ui/sonner.tsx | 4 +- src/features/hall/hall-screen.tsx | 62 ++------- src/features/hall/jackpot-burst-overlay.tsx | 6 +- src/features/player/entry-gate.tsx | 6 +- src/features/player/player-login-screen.tsx | 2 +- .../results/draw-results-list-screen.tsx | 6 +- src/hooks/use-mobile.ts | 23 +++- src/lib/player-spacing.ts | 10 +- src/lib/player-viewport.ts | 47 ++++++- 24 files changed, 394 insertions(+), 181 deletions(-) create mode 100644 src/components/layout/player-desktop-header.tsx create mode 100644 src/components/layout/player-sidebar.tsx diff --git a/src/app/globals.css b/src/app/globals.css index ca7b4c4..d385090 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -49,6 +49,8 @@ } :root { + --player-viewport-max: 480px; + --player-sidebar-width: 0px; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); @@ -163,9 +165,27 @@ background: linear-gradient(to left, rgba(255, 255, 255, 0.95), transparent); } +/* 玩家端侧栏:浅色底 + 与后台一致的红色激活态 */ +.player-sidebar { + --sidebar: #ffffff; + --sidebar-foreground: #475569; + --sidebar-primary: #e60012; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #f1f5f9; + --sidebar-accent-foreground: #0f172a; + --sidebar-border: #e4ebf5; +} + +/* 玩家端桌面壳:侧栏宽度供遮罩/弹层定位 */ +@media (min-width: 1024px) { + .player-desktop-shell { + --player-sidebar-width: 14rem; + } +} + /* 玩家端 Toast:顶部居中、紧凑尺寸(位置见 components/ui/sonner.tsx) */ [data-sonner-toaster] { - --width: min(280px, calc(100vw - 24px)); + --width: min(280px, calc(var(--player-viewport-max, 480px) - 24px)); } [data-sonner-toast][data-styled="true"] { diff --git a/src/components/currency-switcher.tsx b/src/components/currency-switcher.tsx index 16aebee..4182818 100644 --- a/src/components/currency-switcher.tsx +++ b/src/components/currency-switcher.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; +import { playerViewportPopoverMaxClass } from "@/lib/player-viewport"; import { cn } from "@/lib/utils"; type CurrencySwitcherProps = { @@ -109,7 +110,8 @@ export function CurrencySwitcher({ side="bottom" sideOffset={6} className={cn( - "z-[200] w-auto min-w-[min(100vw-2rem,200px)] max-w-[min(100vw-2rem,280px)] p-1 text-gray-900", + "z-[200] w-auto min-w-[11rem] p-1 text-gray-900", + playerViewportPopoverMaxClass, styles.dropdown, )} > diff --git a/src/components/language-switcher.tsx b/src/components/language-switcher.tsx index 6bd60d8..71dc6db 100644 --- a/src/components/language-switcher.tsx +++ b/src/components/language-switcher.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { normalizeLanguage, SUPPORTED_LANGUAGES, type AppLanguage } from "@/i18n"; +import { playerViewportPopoverMaxClass } from "@/lib/player-viewport"; import { cn } from "@/lib/utils"; interface LanguageSwitcherProps { @@ -104,7 +105,8 @@ export function LanguageSwitcher({ side="bottom" sideOffset={6} className={cn( - "z-[200] w-auto min-w-[min(100vw-2rem,220px)] max-w-[min(100vw-2rem,280px)] p-1 text-gray-900", + "z-[200] w-auto min-w-[12rem] p-1 text-gray-900", + playerViewportPopoverMaxClass, styles.dropdown, )} > diff --git a/src/components/layout/player-app-shell.tsx b/src/components/layout/player-app-shell.tsx index 74c2970..d6c0a7c 100644 --- a/src/components/layout/player-app-shell.tsx +++ b/src/components/layout/player-app-shell.tsx @@ -2,48 +2,65 @@ import type { ReactNode } from "react"; -import { NetworkStatusBanner } from "@/components/network-status-banner"; +import { PlayerDesktopHeader } from "@/components/layout/player-desktop-header"; import { PlayerBottomNav } from "@/components/layout/player-bottom-nav"; +import { PlayerSidebar } from "@/components/layout/player-sidebar"; +import { NetworkStatusBanner } from "@/components/network-status-banner"; import { PullToRefreshIndicator } from "@/components/pull-to-refresh-indicator"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { cn } from "@/lib/utils"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; +import { playerMainInset } from "@/lib/player-spacing"; type PlayerAppShellProps = { children: ReactNode; }; /** - * 玩家端外壳:顶栏(品牌 + 会话)+ 主体 + **底部 Tab 导航**(H5)。 - * 底部栏留白:{@link PlayerBottomNav} 作为 sticky 元素放在底部,main 只需要基础 padding。 - * - * 注意:全局离线横幅和服务器错误覆盖层已在 ErrorProvider 中处理 - * 这里的 NetworkStatusBanner 仅用于 WebSocket 状态显示 + * 玩家端外壳: + * - 移动:主体 + 底部 Tab(H5) + * - 桌面:侧栏 + 顶栏 + 宽主内容区(UI 设计稿) */ export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode { + const isMobile = useIsMobile(); const { pullDistance, isRefreshing } = usePullToRefresh({ onRefresh: () => { window.dispatchEvent(new CustomEvent("lottery-hall-refresh")); window.dispatchEvent(new CustomEvent("lottery-wallet-refresh")); }, threshold: 70, + enabled: isMobile, }); return ( -
- - -
- {children} -
-
- +
+ + +
+ {isMobile ? ( + + ) : null} + + + +
+ {children} +
+ +
+ +
); -} +} \ No newline at end of file diff --git a/src/components/layout/player-desktop-header.tsx b/src/components/layout/player-desktop-header.tsx new file mode 100644 index 0000000..5ddd15c --- /dev/null +++ b/src/components/layout/player-desktop-header.tsx @@ -0,0 +1,45 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; + +import { LanguageSwitcher } from "@/components/language-switcher"; +import { PlayerNotificationBell } from "@/components/layout/player-notification-bell"; +import { cn } from "@/lib/utils"; + +type PlayerDesktopHeaderProps = { + className?: string; +}; + +/** 全局顶栏:移动显示 Logo;桌面 Logo 在侧栏顶部,此处仅语言 + 通知 */ +export function PlayerDesktopHeader({ className }: PlayerDesktopHeaderProps) { + return ( +
+ + N lotto + + +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/layout/player-mobile-viewport.tsx b/src/components/layout/player-mobile-viewport.tsx index 5992d14..1cc692b 100644 --- a/src/components/layout/player-mobile-viewport.tsx +++ b/src/components/layout/player-mobile-viewport.tsx @@ -3,7 +3,8 @@ import type { ReactNode } from "react"; import { OfflineBanner } from "@/components/offline-banner"; import { playerSafeAreaTopClass, - playerViewportColumnClass, + playerViewportOuterClass, + playerViewportRootClass, } from "@/lib/player-viewport"; import { cn } from "@/lib/utils"; @@ -13,25 +14,21 @@ type PlayerMobileViewportProps = { }; /** - * 玩家端根容器:真机全宽;桌面浏览器居中为固定宽度的 H5 画布,避免 PC 打开时 UI 横向拉满。 + * 玩家端根容器:移动居中 480px 画布;桌面全宽并配合侧栏壳层(见 UI 设计稿)。 */ export function PlayerMobileViewport({ children, className, }: PlayerMobileViewportProps): ReactNode { return ( -
+
{children}
); -} +} \ No newline at end of file diff --git a/src/components/layout/player-notification-bell.tsx b/src/components/layout/player-notification-bell.tsx index 8b5b9ae..df6765f 100644 --- a/src/components/layout/player-notification-bell.tsx +++ b/src/components/layout/player-notification-bell.tsx @@ -1,12 +1,30 @@ "use client"; +import { Bell } from "lucide-react"; +import Link from "next/link"; import { type ReactElement } from "react"; +import { cn } from "@/lib/utils"; + type PlayerNotificationBellProps = { className?: string; }; -/** 顶栏铃铛:暂隐藏,待接入通用通知中心后再开放 */ -export function PlayerNotificationBell(_props: PlayerNotificationBellProps): ReactElement | null { - return null; -} +/** 顶栏铃铛:跳转通知页(完整通知中心待后续接入) */ +export function PlayerNotificationBell({ + className, +}: PlayerNotificationBellProps): ReactElement { + return ( + + + + + ); +} \ No newline at end of file diff --git a/src/components/layout/player-panel.tsx b/src/components/layout/player-panel.tsx index 8558f08..bf72b70 100644 --- a/src/components/layout/player-panel.tsx +++ b/src/components/layout/player-panel.tsx @@ -1,21 +1,14 @@ "use client"; -import Link from "next/link"; import type { ReactNode } from "react"; -import { ChevronLeft } from "lucide-react"; -import { useTranslation } from "react-i18next"; - -import { LanguageSwitcher } from "@/components/language-switcher"; -import { usePlayerStickyTopStyle } from "@/hooks/use-player-sticky-top-style"; -import { - playerHeaderControl, - playerPageHeader, -} from "@/lib/player-spacing"; +import { playerPageShellPadding } from "@/lib/player-spacing"; +import { playerContentColumnClass } from "@/lib/player-viewport"; import { cn } from "@/lib/utils"; type PlayerPanelProps = { - title: string; + /** 保留供调用方语义标注,不再渲染页内顶栏(导航由外壳侧栏/顶栏承担) */ + title?: string; children: ReactNode; backHref?: string; backLabel?: string; @@ -24,65 +17,21 @@ type PlayerPanelProps = { }; export function PlayerPanel({ - title, children, - backHref = "/hall", - backLabel, className, containerClassName, }: PlayerPanelProps) { - const { t: tp } = useTranslation("player"); - const resolvedBackLabel = backLabel ?? tp("panel.home"); - const stickyTopStyle = usePlayerStickyTopStyle(); - return ( -
+
-
-
- - - {resolvedBackLabel} - -
- -

- {title} -

- -
- -
-
{children}
); -} +} \ No newline at end of file diff --git a/src/components/layout/player-sidebar.tsx b/src/components/layout/player-sidebar.tsx new file mode 100644 index 0000000..7160c89 --- /dev/null +++ b/src/components/layout/player-sidebar.tsx @@ -0,0 +1,118 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { + BarChart3, + BookOpen, + ClipboardList, + Home, + Wallet, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { isCreditFundingPlayer } from "@/lib/player-funding-mode"; +import { cn } from "@/lib/utils"; +import { usePlayerSessionStore } from "@/stores/player-session-store"; + +const navItems = [ + { + href: "/hall", + labelKey: "nav.home", + labelDefault: "首页", + icon: Home, + match: (p: string) => p === "/hall", + }, + { + href: "/results", + labelKey: "nav.results", + labelDefault: "开奖结果", + icon: BarChart3, + match: (p: string) => p === "/results" || p.startsWith("/results/"), + }, + { + href: "/orders", + labelKey: "nav.orders", + labelDefault: "我的注单", + icon: ClipboardList, + match: (p: string) => p === "/orders" || p.startsWith("/orders/"), + }, + { + href: "/wallet", + labelKey: "nav.wallet", + creditLabelKey: "nav.credit", + labelDefault: "钱包", + creditLabelDefault: "信用", + icon: Wallet, + match: (p: string) => p === "/wallet" || p.startsWith("/wallet/"), + }, + { + href: "/rules", + labelKey: "nav.rules", + labelDefault: "规则", + icon: BookOpen, + match: (p: string) => p === "/rules", + }, +] as const; + +/** 与 lotteryadmin `NAV_BTN` / `NAV_ACTIVE` 一致 */ +const playerNavLinkClass = + "flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-[13px] leading-snug font-normal text-sidebar-foreground/90 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0"; + +const playerNavLinkActiveClass = + "bg-sidebar-primary/90 font-medium text-sidebar-primary-foreground shadow-sm hover:bg-sidebar-primary/90 hover:text-sidebar-primary-foreground"; + +/** 桌面端左侧导航(激活态对齐后台侧栏) */ +export function PlayerSidebar() { + const pathname = usePathname() ?? ""; + const { t } = useTranslation("player"); + const creditMode = isCreditFundingPlayer(usePlayerSessionStore((state) => state.profile)); + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/network-status-banner.tsx b/src/components/network-status-banner.tsx index f3e47c6..10cd649 100644 --- a/src/components/network-status-banner.tsx +++ b/src/components/network-status-banner.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; import { usePlayerBannerHeightRef } from "@/hooks/use-player-banner-height-ref"; import { useWebSocketManager } from "@/hooks/use-websocket-manager"; -import { playerViewportColumnClass } from "@/lib/player-viewport"; +import { playerContentColumnClass } from "@/lib/player-viewport"; import { useErrorStore } from "@/stores/error-store"; import { cn } from "@/lib/utils"; @@ -43,7 +43,7 @@ export function NetworkStatusBanner(): React.ReactElement | null {
+
{/* 错误图标 */}
+
{/* 错误图标 */} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 014f5aa..1a3f8f4 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -4,6 +4,10 @@ import * as React from "react" import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { cn } from "@/lib/utils" +import { + playerViewportModalClass, + playerViewportOverlayClass, +} from "@/lib/player-viewport" import { Button } from "@/components/ui/button" import { XIcon } from "lucide-react" @@ -31,7 +35,8 @@ function DialogOverlay({ { "--normal-text": "var(--popover-foreground)", "--normal-border": "var(--border)", "--border-radius": "calc(var(--radius) * 0.8)", - "--width": "min(280px, calc(100vw - 24px))", + "--width": "min(280px, calc(var(--player-viewport-max, 480px) - 24px))", } as React.CSSProperties } toastOptions={{ classNames: { toast: - "cn-toast !min-h-0 !w-full !max-w-[min(280px,calc(100vw-24px))] !items-center !gap-2 !rounded-lg !border !px-3 !py-2 !text-xs !shadow-md", + "cn-toast !min-h-0 !w-full !max-w-[min(280px,calc(var(--player-viewport-max,480px)-24px))] !items-center !gap-2 !rounded-lg !border !px-3 !py-2 !text-xs !shadow-md", title: "!text-xs !font-semibold !leading-snug", description: "!text-[11px] !leading-snug !text-muted-foreground", icon: "!mr-0 !size-3.5", diff --git a/src/features/hall/hall-screen.tsx b/src/features/hall/hall-screen.tsx index 087b792..b8f4c0b 100644 --- a/src/features/hall/hall-screen.tsx +++ b/src/features/hall/hall-screen.tsx @@ -1,21 +1,16 @@ "use client"; -import { BookOpen } from "lucide-react"; -import Image from "next/image"; -import Link from "next/link"; import { useTranslation } from "react-i18next"; -import { LanguageSwitcher } from "@/components/language-switcher"; import { HallBettingGrid } from "@/features/hall/hall-betting-grid"; -import { usePlayerStickyTopStyle } from "@/hooks/use-player-sticky-top-style"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { HallDrawPanel } from "@/features/hall/hall-draw-panel"; import { HallWalletStrip } from "@/features/hall/hall-wallet-strip"; import { JackpotBurstOverlay } from "@/features/hall/jackpot-burst-overlay"; import { useHallDrawLive } from "@/features/hall/use-hall-draw-live"; import { useJackpotBurstLive } from "@/features/hall/use-jackpot-burst-live"; -import { playerHeaderControl, playerPageHeader, playerPageInset } from "@/lib/player-spacing"; -import { playerViewportColumnClass } from "@/lib/player-viewport"; +import { playerPageInset } from "@/lib/player-spacing"; +import { playerContentColumnClass } from "@/lib/player-viewport"; import { cn } from "@/lib/utils"; /** @@ -26,52 +21,15 @@ export function HallScreen() { const drawLive = useHallDrawLive(); const { activeCurrency } = useActivePlayerCurrency(); const { burstEvent, clearBurstEvent } = useJackpotBurstLive(tp); - const stickyTopStyle = usePlayerStickyTopStyle(); return ( -
-
-
-
- Nlotto -
-
- - - - {tp("nav.rules")} - -
-
- +
+
@@ -81,4 +39,4 @@ export function HallScreen() {
); -} +} \ No newline at end of file diff --git a/src/features/hall/jackpot-burst-overlay.tsx b/src/features/hall/jackpot-burst-overlay.tsx index 2513ef6..3df39ea 100644 --- a/src/features/hall/jackpot-burst-overlay.tsx +++ b/src/features/hall/jackpot-burst-overlay.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import { getDrawResultByNo } from "@/api/draw"; import { Button } from "@/components/ui/button"; import { formatMinorAsCurrency } from "@/lib/money"; +import { playerViewportOverlayClass } from "@/lib/player-viewport"; import { cn } from "@/lib/utils"; export type JackpotBurstEvent = { @@ -214,7 +215,10 @@ function JackpotBurstOverlayContent({ return (
{phase === "loading" || waitingForEmbeddedToken ? ( -
+
@@ -525,7 +525,7 @@ export function EntryGate() { ) : null} {phase === "failed" ? ( -
+
@@ -601,7 +601,7 @@ export function EntryGate() { ) : null} {phase === "success" ? ( -
+
diff --git a/src/features/player/player-login-screen.tsx b/src/features/player/player-login-screen.tsx index e9e0a44..7e7b45d 100644 --- a/src/features/player/player-login-screen.tsx +++ b/src/features/player/player-login-screen.tsx @@ -160,7 +160,7 @@ export function PlayerLoginScreen(): React.ReactElement {
-
+

{t("login.title")}

diff --git a/src/features/results/draw-results-list-screen.tsx b/src/features/results/draw-results-list-screen.tsx index 540c67f..6b4ee13 100644 --- a/src/features/results/draw-results-list-screen.tsx +++ b/src/features/results/draw-results-list-screen.tsx @@ -117,7 +117,8 @@ export function DrawResultsListScreen() {

{t("results.businessDate")}

-
+
+
+
{date ? (