feat: 优化整体项目ui

This commit is contained in:
JiaJun
2026-05-22 17:58:52 +08:00
parent 44c984d59e
commit 046f250ce3
56 changed files with 2149 additions and 700 deletions

View File

@@ -0,0 +1,235 @@
import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { RotateCw } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
import checkIcon from '@/assets/system/right.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import {
ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY,
} from '@/constants'
import { getNoticeList } from '@/features/game/api'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
function getLastConfirmedAt(storageKey: string) {
if (typeof localStorage === 'undefined') {
return null
}
const value = Number(localStorage.getItem(storageKey))
return Number.isFinite(value) && value > 0 ? value : null
}
function setLastConfirmedAt(storageKey: string, timestamp: number) {
if (typeof localStorage === 'undefined') {
return
}
localStorage.setItem(storageKey, String(timestamp))
}
export function EntryNoticeGateModal() {
const { t } = useTranslation()
const authStatus = useAuthStore((state) => state.status)
const authIsHydrated = useAuthStore((state) => state.isHydrated)
const accessToken = useAuthStore((state) => state.accessToken)
const currentUserId = useAuthStore((state) => state.currentUser?.id)
const [hasEntered, setHasEntered] = useState(false)
const [hasAgreed, setHasAgreed] = useState(false)
const [shouldGateEntry, setShouldGateEntry] = useState(false)
const hasStoredLoginInfo =
authStatus === 'authenticated' && Boolean(accessToken)
const confirmedAtStorageKey = `${ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY}:${
currentUserId ?? 'authenticated'
}`
useEffect(() => {
if (!authIsHydrated) {
return
}
setHasEntered(false)
setHasAgreed(false)
if (!hasStoredLoginInfo) {
setShouldGateEntry(true)
return
}
const lastConfirmedAt = getLastConfirmedAt(confirmedAtStorageKey)
setShouldGateEntry(
!lastConfirmedAt ||
Date.now() - lastConfirmedAt >= ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
)
}, [authIsHydrated, confirmedAtStorageKey, hasStoredLoginInfo])
const noticeListQuery = useQuery({
queryKey: ['game', 'entry-notice-list'],
queryFn: () => getNoticeList({ page: 1, pageSize: 20 }),
enabled: authIsHydrated && shouldGateEntry,
staleTime: 0,
})
const popoutNotices = useMemo(
() =>
(noticeListQuery.data?.list ?? []).filter(
(notice) => notice.notice_type === 'popout',
),
[noticeListQuery.data],
)
const shouldShowModal =
authIsHydrated &&
shouldGateEntry &&
!hasEntered &&
(noticeListQuery.isPending ||
noticeListQuery.isError ||
popoutNotices.length > 0)
const canEnter =
hasAgreed && !noticeListQuery.isPending && popoutNotices.length > 0
if (!shouldShowModal) {
return null
}
return (
<CenterModal
open={shouldShowModal}
isShowClose={false}
isNormalBg={true}
title={
<div className="modal-title-glow text-design-26">
{t('game.modals.entryNotice.title')}
</div>
}
titleAlign="left"
className="h-design-700 w-design-1000 max-h-[92vh] max-w-[92vw]"
>
<div className="flex h-full w-full flex-col gap-design-20 px-design-14 pb-design-30 pt-design-8">
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border border-[#2B8CA3]/45 bg-[#001B24]/70 p-design-18 shadow-[inset_0_0_calc(var(--design-unit)*18)_rgba(39,175,205,0.1)]">
{noticeListQuery.isPending ? (
<div className="flex h-full min-h-[calc(var(--design-unit)*320)] items-center justify-center text-design-22 text-[#9CE8F2]">
{t('game.modals.entryNotice.loading')}
</div>
) : noticeListQuery.isError ? (
<div className="flex h-full min-h-[calc(var(--design-unit)*320)] flex-col items-center justify-center gap-design-18 text-center text-[#9CE8F2]">
<div className="text-design-22">
{t('game.modals.entryNotice.loadFailed')}
</div>
<button
type="button"
onClick={() => {
void noticeListQuery.refetch()
}}
className="inline-flex items-center gap-design-8 rounded-md border border-[#4AC6DE]/45 bg-[#0B4454] px-design-18 py-design-10 text-design-18 text-[#D7FFFF] transition hover:bg-[#0E576D]"
>
<RotateCw className="h-design-18 w-design-18" />
{t('game.modals.entryNotice.retry')}
</button>
</div>
) : (
<div className="flex flex-col gap-design-16">
{popoutNotices.map((notice, index) => (
<article
key={notice.notice_id}
className="rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)] p-design-20"
>
<div className="mb-design-12 flex flex-wrap items-center justify-between gap-design-12">
<div className="min-w-0 flex-1 text-design-24 font-semibold leading-tight text-white">
{index + 1}. {notice.title}
</div>
<div className="rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-12 py-design-5 text-design-15 text-[#9CE8F2]">
{dayjs(notice.publish_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)}
</div>
</div>
<div className="whitespace-pre-wrap text-design-18 leading-[1.8] text-[#C4F2F7]">
{notice.content ?? ''}
</div>
</article>
))}
</div>
)}
</div>
<div className="flex shrink-0 items-center justify-center gap-design-28">
<label className="inline-flex cursor-pointer items-center gap-design-12 text-design-20 text-[#C4F2F7]">
<input
type="checkbox"
checked={hasAgreed}
disabled={noticeListQuery.isPending || noticeListQuery.isError}
onChange={(event) => setHasAgreed(event.target.checked)}
className="peer sr-only"
/>
<span
aria-hidden="true"
className={cn(
'flex h-design-32 w-design-32 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*5)] border transition',
hasAgreed
? 'border-[#4AFF49]/80 bg-[#071F11]'
: 'border-[#6CCDCF]/70 bg-[#031D25]',
)}
>
{hasAgreed ? (
<SmartImage
src={checkIcon}
alt=""
priority={true}
showSkeleton={false}
className="h-design-34 w-design-38 overflow-visible"
imgClassName="object-contain"
/>
) : null}
</span>
<span>{t('game.modals.entryNotice.agreement')}</span>
</label>
<SmartBackground
as="button"
type="button"
src={lengthGreenBtn}
size="106% 108%"
repeat="no-repeat"
position="center"
disabled={!canEnter}
onClick={() => {
if (canEnter) {
if (hasStoredLoginInfo) {
setLastConfirmedAt(confirmedAtStorageKey, Date.now())
}
setHasEntered(true)
}
}}
className={cn(
'flex h-design-72 w-design-270 items-center justify-center rounded-md pb-design-5 text-design-22 font-bold transition',
canEnter
? 'modal-title-glow cursor-pointer text-white hover:brightness-110 active:brightness-95'
: 'cursor-not-allowed text-white opacity-80 grayscale',
)}
style={
canEnter
? undefined
: {
filter: 'grayscale(100%)',
WebkitFilter: 'grayscale(100%)',
}
}
>
{t('game.modals.entryNotice.enterGame')}
</SmartBackground>
</div>
</div>
</CenterModal>
)
}

View File

@@ -1,114 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
type GameTone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger'
interface GameOverlayAction {
label: string
onClick: () => void
tone?: GameTone
}
interface GameAnnouncementModalProps {
open: boolean
title: string
description?: ReactNode
eyebrow?: string
tone?: GameTone
primaryAction?: GameOverlayAction
secondaryAction?: GameOverlayAction
children?: ReactNode
}
const toneClasses: Record<GameTone, string> = {
neutral: 'border-white/10 bg-slate-950/92',
brand: 'border-cyan-300/25 bg-slate-950/94',
success: 'border-emerald-300/25 bg-slate-950/94',
warning: 'border-amber-300/25 bg-slate-950/94',
danger: 'border-rose-300/25 bg-slate-950/94',
}
const actionToneClasses: Record<GameTone, string> = {
neutral: 'border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.1]',
brand: 'border-cyan-300/25 bg-cyan-300/14 text-cyan-50 hover:bg-cyan-300/22',
success:
'border-emerald-300/25 bg-emerald-300/14 text-emerald-50 hover:bg-emerald-300/22',
warning:
'border-amber-300/25 bg-amber-300/14 text-amber-50 hover:bg-amber-300/22',
danger: 'border-rose-300/25 bg-rose-300/14 text-rose-50 hover:bg-rose-300/22',
}
function ModalAction({ label, onClick, tone = 'brand' }: GameOverlayAction) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex min-h-12 items-center justify-center rounded-full border px-5 text-sm font-semibold tracking-[0.18em] uppercase transition duration-200',
actionToneClasses[tone],
)}
>
{label}
</button>
)
}
export function GameAnnouncementModal({
open,
title,
description,
eyebrow = '',
tone = 'brand',
primaryAction,
secondaryAction,
children,
}: GameAnnouncementModalProps) {
if (!open) {
return null
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/82 px-4 py-8 backdrop-blur-md">
<div
role="dialog"
aria-modal="true"
aria-labelledby="game-announcement-title"
className={cn(
'w-full max-w-xl rounded-[32px] border p-6 shadow-[0_40px_120px_-40px_rgba(15,23,42,0.95)] sm:p-7',
toneClasses[tone],
)}
>
<div className="space-y-4">
<div className="space-y-2">
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[0.68rem] font-semibold tracking-[0.24em] text-slate-300 uppercase">
{eyebrow}
</span>
<h2
id="game-announcement-title"
className="text-2xl font-semibold tracking-tight text-white sm:text-[2rem]"
>
{title}
</h2>
{description ? (
<div className="text-sm leading-7 text-slate-300">
{description}
</div>
) : null}
</div>
{children ? (
<div className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4">
{children}
</div>
) : null}
{secondaryAction || primaryAction ? (
<div className="flex flex-wrap gap-3 pt-2">
{secondaryAction ? <ModalAction {...secondaryAction} /> : null}
{primaryAction ? <ModalAction {...primaryAction} /> : null}
</div>
) : null}
</div>
</div>
</div>
)
}