feat(player): PC 端列表/布局适配与分页
玩家端桌面:全宽居中 Logo、圆角侧栏与主内容面板;开奖结果/注单改 shadcn Table+分页(移动仍无限滚动),注单组默认折叠;信用/钱包筛选与分页;详情页与奖号网格密度优化。
This commit is contained in:
@@ -18,8 +18,8 @@ type PlayerAppShellProps = {
|
||||
|
||||
/**
|
||||
* 玩家端外壳:
|
||||
* - 移动:主体 + 底部 Tab(H5)
|
||||
* - 桌面:侧栏 + 顶栏 + 宽主内容区(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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
157
src/components/layout/player-list-pagination.tsx
Normal file
157
src/components/layout/player-list-pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
127
src/components/ui/pagination.tsx
Normal file
127
src/components/ui/pagination.tsx
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user