feat(player): PC 端列表/布局适配与分页

玩家端桌面:全宽居中 Logo、圆角侧栏与主内容面板;开奖结果/注单改 shadcn Table+分页(移动仍无限滚动),注单组默认折叠;信用/钱包筛选与分页;详情页与奖号网格密度优化。
This commit is contained in:
2026-07-15 10:38:49 +08:00
parent c74bd5f701
commit 295cfb97cd
23 changed files with 2989 additions and 1411 deletions

View File

@@ -18,8 +18,8 @@ type PlayerAppShellProps = {
/**
* 玩家端外壳:
* - 移动:主体 + 底部 TabH5
* - 桌面:侧栏 + 栏 + 主内容UI 设计稿)
* - 移动:顶栏 + 主体 + 底部 Tab
* - 桌面:全宽顶栏Logo 整页居中)+ 栏 + 主内容
*/
export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
const isMobile = useIsMobile();
@@ -33,34 +33,37 @@ export function PlayerAppShell({ children }: PlayerAppShellProps): ReactNode {
});
return (
<div className="flex h-full min-h-0 flex-1 overflow-hidden bg-white text-foreground lg:flex-row">
<PlayerSidebar />
<div className="player-desktop-shell flex h-full min-h-0 flex-1 flex-col overflow-hidden bg-white text-foreground lg:bg-[#f0f2f6]">
<PlayerDesktopHeader />
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{isMobile ? (
<PullToRefreshIndicator
pullDistance={pullDistance}
isRefreshing={isRefreshing}
threshold={70}
/>
) : null}
<NetworkStatusBanner />
<PlayerDesktopHeader />
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden lg:flex-row lg:gap-0 lg:px-0">
<PlayerSidebar />
<main
id="player-scroll-container"
className={cn(
"flex min-h-0 w-full flex-1 flex-col overflow-y-auto overscroll-y-contain bg-[#f8fafc] lg:bg-[#f0f2f6]",
playerMainInset,
)}
>
{children}
</main>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:py-4 lg:pr-4">
{isMobile ? (
<PullToRefreshIndicator
pullDistance={pullDistance}
isRefreshing={isRefreshing}
threshold={70}
/>
) : null}
<NetworkStatusBanner />
<div className="relative z-50 w-full shrink-0 lg:hidden">
<PlayerBottomNav />
<main
id="player-scroll-container"
className={cn(
"flex min-h-0 w-full flex-1 flex-col overflow-y-auto overscroll-y-contain bg-[#f8fafc] lg:rounded-2xl lg:border lg:border-[#e4ebf5] lg:bg-white lg:shadow-[0_8px_24px_rgba(15,23,42,0.04)]",
playerMainInset,
)}
>
{children}
</main>
<div className="relative z-50 w-full shrink-0 lg:hidden">
<PlayerBottomNav />
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -10,15 +10,16 @@ type PlayerDesktopHeaderProps = {
className?: string;
};
/** 全局顶栏:移动显示 Logo桌面 Logo 在侧栏顶部,此处仅语言切换 */
/** 全局顶栏:移动 Logo 左对齐;桌面 Logo 整页水平居中,语言切换靠右 */
export function PlayerDesktopHeader({ className }: PlayerDesktopHeaderProps) {
return (
<header
className={cn(
"flex h-12 shrink-0 items-center justify-between border-b border-[#e4ebf5] bg-white px-3 lg:h-14 lg:justify-end lg:px-6",
"relative z-20 flex h-12 shrink-0 items-center justify-between border-b border-[#e4ebf5] bg-white px-3 lg:h-14 lg:px-6",
className,
)}
>
{/* 移动:左 Logo */}
<Link href="/hall" className="inline-flex min-w-0 items-center lg:hidden" aria-label="N lotto">
<Image
src="/logo.png"
@@ -30,7 +31,23 @@ export function PlayerDesktopHeader({ className }: PlayerDesktopHeaderProps) {
/>
</Link>
<div className="flex shrink-0 items-center gap-2">
{/* 桌面:整页水平居中 Logo */}
<Link
href="/hall"
className="pointer-events-auto absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 lg:inline-flex"
aria-label="N lotto"
>
<Image
src="/logo.png"
alt="N lotto"
width={243}
height={84}
className="h-8 w-auto max-w-[180px] object-contain"
priority
/>
</Link>
<div className="ml-auto flex shrink-0 items-center gap-2">
<LanguageSwitcher
variant="minimal"
menuAlign="end"

View File

@@ -0,0 +1,157 @@
"use client";
import { useTranslation } from "react-i18next";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { cn } from "@/lib/utils";
/** 页码切片:首尾 + 当前邻页,中间用省略号 */
export function playerListPageSlices(
current: number,
last: number,
delta = 1,
): (number | "ellipsis")[] {
if (last <= 1) {
return last === 1 ? [1] : [];
}
const cap = delta * 2 + 3;
if (last <= cap) {
return Array.from({ length: last }, (_, i) => i + 1);
}
const set = new Set<number>([1, last]);
for (let p = current - delta; p <= current + delta; p++) {
if (p >= 1 && p <= last) set.add(p);
}
const sorted = [...set].sort((a, b) => a - b);
const out: (number | "ellipsis")[] = [];
let prev = 0;
for (const p of sorted) {
if (prev && p - prev > 1) out.push("ellipsis");
out.push(p);
prev = p;
}
return out;
}
type PlayerListPaginationProps = {
page: number;
lastPage: number;
total?: number;
loading?: boolean;
onPageChange: (page: number) => void;
className?: string;
/** 隐藏「共 x 条」摘要 */
hideSummary?: boolean;
};
/** PC 列表底部分页(移动端仍用无限滚动,勿挂载本组件) */
export function PlayerListPagination({
page,
lastPage,
total,
loading = false,
onPageChange,
className,
hideSummary = false,
}: PlayerListPaginationProps) {
const { t } = useTranslation("player");
if (lastPage <= 1 && (total == null || total <= 0)) {
return null;
}
return (
<div
className={cn(
"hidden items-center justify-between gap-3 border-t border-[#e9eff8] bg-[#fbfdff] px-4 py-3 lg:flex",
className,
)}
>
{!hideSummary ? (
<p className="text-xs font-semibold tabular-nums text-[#59739f]">
{total != null
? t("pagination.summary", {
defaultValue: "共 {{total}} 条,第 {{page}} / {{lastPage}} 页",
total,
page,
lastPage: Math.max(1, lastPage),
})
: t("pagination.pageOf", {
defaultValue: "第 {{page}} / {{lastPage}} 页",
page,
lastPage: Math.max(1, lastPage),
})}
</p>
) : (
<span />
)}
{lastPage > 1 ? (
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent className="flex-wrap justify-end">
<PaginationItem>
<PaginationPrevious
type="button"
text={t("pagination.previous", { defaultValue: "上一页" })}
disabled={page <= 1 || loading}
className="h-8 rounded-lg border border-[#dce7f7] bg-white text-[#32518d] hover:bg-[#f8fbff]"
onClick={() => {
if (page <= 1 || loading) return;
onPageChange(Math.max(1, page - 1));
}}
/>
</PaginationItem>
{playerListPageSlices(page, lastPage).map((item, idx) =>
item === "ellipsis" ? (
<PaginationItem key={`e-${idx}`}>
<PaginationEllipsis className="text-[#7890b8]" />
</PaginationItem>
) : (
<PaginationItem key={item}>
<PaginationLink
type="button"
size="icon-sm"
isActive={item === page}
disabled={loading}
className={cn(
"min-w-8 tabular-nums",
item === page
? "border-[#0b3f96] bg-white font-black text-[#0b3f96]"
: "text-[#32518d] hover:bg-[#f8fbff]",
)}
onClick={() => {
if (loading || item === page) return;
onPageChange(item);
}}
>
{item}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext
type="button"
text={t("pagination.next", { defaultValue: "下一页" })}
disabled={page >= lastPage || loading}
className="h-8 rounded-lg border border-[#dce7f7] bg-white text-[#32518d] hover:bg-[#f8fbff]"
onClick={() => {
if (page >= lastPage || loading) return;
onPageChange(Math.min(lastPage, page + 1));
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
) : null}
</div>
);
}

View File

@@ -1,6 +1,5 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -57,14 +56,13 @@ const navItems = [
},
] 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";
"flex h-10 w-full items-center gap-2 rounded-xl px-3 text-sm leading-none font-medium 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";
"bg-sidebar-primary/90 font-semibold 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");
@@ -72,23 +70,10 @@ export function PlayerSidebar() {
return (
<aside
className="player-sidebar hidden h-full w-56 shrink-0 flex-col border-r border-[#e4ebf5] bg-white lg:flex"
className="player-sidebar hidden w-[13.5rem] shrink-0 flex-col py-4 pl-4 pr-0 lg:flex"
aria-label={t("nav.aria")}
>
<div className="shrink-0 border-b border-[#e4ebf5] px-2 py-2">
<Link href="/hall" className="flex h-10 items-center px-1" aria-label="N lotto">
<Image
src="/logo.png"
alt="N lotto"
width={243}
height={84}
className="h-auto max-h-10 w-full object-contain object-left"
priority
/>
</Link>
</div>
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1.5 py-1.5">
<nav className="flex h-full min-h-0 flex-col gap-1 overflow-y-auto rounded-2xl border border-[#e4ebf5] bg-white p-2 shadow-[0_8px_24px_rgba(15,23,42,0.04)]">
{navItems.map(({ href, labelKey, labelDefault, icon: Icon, match, ...item }) => {
const active = match(pathname);
const creditLabelKey = "creditLabelKey" in item ? item.creditLabelKey : undefined;
@@ -115,4 +100,4 @@ export function PlayerSidebar() {
</nav>
</aside>
);
}
}

View File

@@ -0,0 +1,127 @@
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
disabled?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
Omit<React.ComponentProps<typeof Button>, "size" | "variant">;
function PaginationLink({
className,
isActive,
size = "icon",
disabled,
...props
}: PaginationLinkProps) {
return (
<Button
variant={isActive ? "outline" : "ghost"}
size={size}
disabled={disabled}
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive || undefined}
className={cn(className)}
{...props}
/>
);
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2!", className)}
{...props}
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
);
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<MoreHorizontalIcon />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};