feat(auth): 添加登出功能并优化认证处理

- 添加了登出相关的API端点和常量定义
- 实现了登出功能及密码验证登出逻辑
- 添加了登出会话清理和浏览器存储清除
- 在用户信息模态框中集成了登出按钮
- 添加了登出相关的国际化翻译
- 优化了API客户端中的认证错误处理
- 实现了无效令牌的自动处理机制
- 更新了GitNexus索引统计数据
- 修改了构建输出目录配置
- 清理了不必要的注释和代码
- 调整了移动端头部组件结构
- 优化了游戏历史记录查询逻辑
- 添加了控制台日志用于调试
- 设置默认注册邀请码为D97DBC16
- 在.gitignore中添加构建产物忽略规则
This commit is contained in:
JiaJun
2026-05-29 16:26:35 +08:00
parent dbfe5701aa
commit 15c519a42c
26 changed files with 527 additions and 87 deletions

View File

@@ -5,3 +5,4 @@ VITE_ENABLE_QUERY_DEVTOOLS=false
VITE_ENABLE_REQUEST_LOG=false VITE_ENABLE_REQUEST_LOG=false
# 客户端密钥 # 客户端密钥
VITE_AUTH_TOKEN_SECRET=564d14asdasd113e46542asd6das1a2a VITE_AUTH_TOKEN_SECRET=564d14asdasd113e46542asd6das1a2a

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@ pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
zihua-web.zip
zihua-web
dist dist
dist-ssr dist-ssr
coverage coverage

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **36-character-flower** (2394 symbols, 4479 relationships, 203 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **36-character-flower** (2505 symbols, 4694 relationships, 215 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **36-character-flower** (2394 symbols, 4479 relationships, 203 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **36-character-flower** (2505 symbols, 4694 relationships, 215 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -4,11 +4,15 @@ export const AUTH_STORAGE_KEY = 'auth-session'
/** @description 认证模块调用的后端接口地址集合。 */ /** @description 认证模块调用的后端接口地址集合。 */
export const AUTH_ENDPOINTS = { export const AUTH_ENDPOINTS = {
login: 'api/user/login', login: 'api/user/login',
logout: 'api/user/logout',
profile: 'api/user/profile', profile: 'api/user/profile',
refreshToken: 'api/user/refreshToken', refreshToken: 'api/user/refreshToken',
register: 'api/user/register', register: 'api/user/register',
} as const } as const
/** @description 后端返回该 code 表示登录态 token 无效或已过期。 */
export const AUTH_INVALID_TOKEN_CODE = 1101
/** @description 获取接口鉴权 auth-token 时使用的接口地址。 */ /** @description 获取接口鉴权 auth-token 时使用的接口地址。 */
export const AUTH_TOKEN_ENDPOINT = 'api/v1/authToken' export const AUTH_TOKEN_ENDPOINT = 'api/v1/authToken'

View File

@@ -9,6 +9,8 @@ import type {
AuthUserProfileDto, AuthUserProfileDto,
LoginPayload, LoginPayload,
LoginRequestDto, LoginRequestDto,
LogoutPayload,
LogoutRequestDto,
RefreshTokenDto, RefreshTokenDto,
RefreshTokenRequestDto, RefreshTokenRequestDto,
RegisterPayload, RegisterPayload,
@@ -107,6 +109,25 @@ export async function loginWithPassword(
return session return session
} }
export async function logoutWithPassword(
payload: LogoutPayload,
): Promise<void> {
const response = await api.post<null, LogoutRequestDto>(
AUTH_ENDPOINTS.logout,
{
json: {
password: payload.password,
username: payload.username,
},
},
)
unwrapEnvelope(
response as ApiResponse<null>,
'auth.logout.errors.submitFailed',
)
}
export async function registerWithPassword( export async function registerWithPassword(
payload: RegisterPayload, payload: RegisterPayload,
): Promise<AuthSessionInput> { ): Promise<AuthSessionInput> {

View File

@@ -56,6 +56,11 @@ export interface LoginRequestDto {
username: string username: string
} }
export interface LogoutRequestDto {
password: string
username: string
}
export interface RegisterRequestDto extends LoginRequestDto { export interface RegisterRequestDto extends LoginRequestDto {
invite_code: string invite_code: string
} }
@@ -69,6 +74,8 @@ export interface LoginPayload {
username: string username: string
} }
export type LogoutPayload = LoginPayload
export interface RegisterPayload extends LoginPayload { export interface RegisterPayload extends LoginPayload {
inviteCode: string inviteCode: string
} }

View File

@@ -16,16 +16,17 @@ interface UseRegisterFormOptions {
} }
const REGISTER_INVITE_CODE_QUERY_PARAM = 'registerInviteCode' const REGISTER_INVITE_CODE_QUERY_PARAM = 'registerInviteCode'
const DEFAULT_REGISTER_INVITE_CODE = 'D97DBC16'
function getInitialRegisterInviteCode() { function getInitialRegisterInviteCode() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return '' return DEFAULT_REGISTER_INVITE_CODE
} }
return ( return (
new URLSearchParams(window.location.search) new URLSearchParams(window.location.search)
.get(REGISTER_INVITE_CODE_QUERY_PARAM) .get(REGISTER_INVITE_CODE_QUERY_PARAM)
?.trim() ?? '' ?.trim() || DEFAULT_REGISTER_INVITE_CODE
) )
} }

View File

@@ -10,7 +10,6 @@ import {
VolumeX, VolumeX,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import add from '@/assets/game/add.webp'
import avatar from '@/assets/system/avatar.webp' import avatar from '@/assets/system/avatar.webp'
import diamond from '@/assets/system/diamond.webp' import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp' import logo from '@/assets/system/logo.webp'

View File

@@ -13,6 +13,7 @@ import { SmartImage } from '@/components/smart-image.tsx'
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx' import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx' import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts' import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
import { cn } from '@/lib/utils.ts'
export function DesktopStatusLine() { export function DesktopStatusLine() {
const { t } = useTranslation() const { t } = useTranslation()
@@ -20,7 +21,6 @@ export function DesktopStatusLine() {
countdownMs, countdownMs,
limitLabel, limitLabel,
oddsLabel, oddsLabel,
phaseDescription,
phaseLabel, phaseLabel,
phaseToneClassName, phaseToneClassName,
roundId, roundId,
@@ -40,6 +40,17 @@ export function DesktopStatusLine() {
return ( return (
<div className={'relative w-full flex flex-col text-design-22'}> <div className={'relative w-full flex flex-col text-design-22'}>
{/*<div*/}
{/* className={*/}
{/* 'absolute top-design-75 left-1/2 -translate-x-1/2 -z-10 w-full px-design-16'*/}
{/* }*/}
{/*>*/}
{/* <DesktopTitle />*/}
{/*</div>*/}
<div className={'w-full px-design-16 mb-design-10'}>
<DesktopTitle />
</div>
<SmartBackground <SmartBackground
src={statusLine} src={statusLine}
size="100% 100%" size="100% 100%"
@@ -154,28 +165,25 @@ export function DesktopStatusLine() {
className={countdownClassName} className={countdownClassName}
/> />
</div> </div>
<div className={'flex-1 flex items-center justify-center gap-10'}> <div
<div> className={'flex-1 flex items-center justify-center gap-design-100'}
{t('gameDesktop.status.roundId')}:{roundId} >
</div>
<div className={'flex items-center gap-2'}> <div className={'flex items-center gap-2'}>
<div className={'flex items-center gap-2'}> <div className={'flex items-center gap-2'}>
<div <div
className={'w-design-20 h-design-20 bg-[#78FF7F] rounded-[50%]'} className={'w-design-20 h-design-20 bg-[#78FF7F] rounded-[50%]'}
></div> ></div>
<div className={phaseToneClassName}>{phaseLabel}</div> <div className={cn(phaseToneClassName, 'font-bold')}>
{phaseLabel}
</div>
</div> </div>
<div>{phaseDescription}</div> </div>
<div className={'text-[#CBD3D5] font-bold'}>
{t('gameDesktop.status.roundId')}:{roundId}
</div> </div>
</div> </div>
</SmartBackground> </SmartBackground>
<div
className={
'absolute top-design-75 left-1/2 -translate-x-1/2 -z-10 w-full px-design-16'
}
>
<DesktopTitle />
</div>
</div> </div>
) )
} }

View File

@@ -1,10 +1,8 @@
import { useTranslation } from 'react-i18next'
import broadcast from '@/assets/system/broadcast.webp' import broadcast from '@/assets/system/broadcast.webp'
import { SmartImage } from '@/components/smart-image.tsx' import { SmartImage } from '@/components/smart-image.tsx'
import { useGameSessionStore } from '@/store/game' import { useGameSessionStore } from '@/store/game'
export function DesktopTitle() { export function DesktopTitle() {
const { t } = useTranslation()
const jackpotBroadcasts = useGameSessionStore( const jackpotBroadcasts = useGameSessionStore(
(state) => state.jackpotBroadcasts, (state) => state.jackpotBroadcasts,
) )
@@ -30,9 +28,6 @@ export function DesktopTitle() {
alt={'broadcast'} alt={'broadcast'}
src={broadcast} src={broadcast}
/> />
<div className="shrink-0 !text-[#FF970F]">
{t('gameDesktop.title.announcement')}:
</div>
<div className="relative h-design-28 min-w-0 flex-1 overflow-hidden"> <div className="relative h-design-28 min-w-0 flex-1 overflow-hidden">
<div <div
className={ className={

View File

@@ -0,0 +1,223 @@
import {
Info,
Mail,
UserKey,
UserRoundPlus,
Volume2,
VolumeX,
Wifi,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import avatar from '@/assets/system/avatar.webp'
import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import { useHeaderVm } from '@/features/game/hooks/use-header-vm'
export function MobileHeader() {
const { t } = useTranslation()
const {
authStatus,
currentLanguageLabel,
currentLanguageOption,
currentUser,
isSoundEnabled,
onOpenLanguage,
onOpenLogin,
onOpenNotice,
onOpenProcedures,
onOpenRegister,
onOpenRules,
onOpenUserInfo,
signalPresentation,
systemTimeLabel,
toggleSoundEnabled,
} = useHeaderVm()
const actionButtonClassName =
'common-neon-inset flex h-design-19 shrink-0 cursor-pointer items-center justify-center gap-design-5 !rounded-[3px] !px-design-5 !py-0 text-design-7 leading-none transition-opacity hover:opacity-85'
const accountButtonClassName =
'common-neon-inset flex h-design-19 items-center justify-end !rounded-[3px] !py-0 text-design-7 leading-none transition-[opacity,transform] duration-150 group-hover:opacity-90 group-active:scale-[0.98]'
return (
<header className="sticky top-0 z-30 h-design-62">
<div className="border-b-2 border-[#787553] bg-[#020B14]] bg-[#020B14] flex h-design-33 w-full items-center">
<div className="flex h-design-23 w-design-130 shrink-0 items-center justify-center border-r border-[rgba(128,223,231,0.45)] px-design-10">
<SmartImage
src={logo}
alt="logo"
priority
className="h-full w-full"
imgClassName="object-contain"
/>
</div>
{authStatus === 'authenticated' ? (
<div className="flex h-full min-w-0 flex-1 items-center justify-end gap-design-7 px-design-9">
<button
type="button"
onClick={onOpenUserInfo}
className="group relative flex min-w-0 items-center justify-center transition-transform duration-150 hover:-translate-y-[1px] active:translate-y-[1px]"
>
<SmartImage
src={avatar}
alt="avatar"
priority
className="absolute left-0 z-20 h-design-25 w-design-25 rounded-full"
/>
<div
className={`${accountButtonClassName} w-design-92 pl-design-28 pr-design-6`}
>
<span className="truncate">
{currentUser?.username || '--'}
</span>
</div>
</button>
<button
type="button"
onClick={onOpenProcedures}
className="group relative flex min-w-0 items-center justify-center transition-transform duration-150 hover:-translate-y-[1px] active:translate-y-[1px]"
>
<SmartImage
src={diamond}
alt="diamond"
priority
className="absolute left-0 z-20 h-design-25 w-design-25"
/>
<div
className={`${accountButtonClassName} w-design-90 gap-design-4 pl-design-26 pr-design-4`}
>
<span className="min-w-0 truncate">
{currentUser?.coin || '--'}
</span>
{/*<div className="common-neon-inset flex h-design-15 w-design-15 shrink-0 items-center justify-center !rounded-[3px] !p-0">*/}
{/* <Plus*/}
{/* aria-hidden="true"*/}
{/* className="h-design-10 w-design-10 shrink-0"*/}
{/* color="#57B8BF"*/}
{/* strokeWidth={2.5}*/}
{/* />*/}
{/*</div>*/}
</div>
</button>
</div>
) : (
<div className="flex h-full min-w-0 flex-1 items-center justify-end gap-design-8 px-design-9">
<button
type="button"
className={`${actionButtonClassName} w-design-72`}
onClick={onOpenLogin}
>
<UserKey
className="h-design-8 w-design-8 shrink-0"
color="#57B8BF"
/>
<div className="min-w-0 truncate">
{t('gameDesktop.header.login')}
</div>
</button>
<button
type="button"
className={`${actionButtonClassName} w-design-78`}
onClick={onOpenRegister}
>
<UserRoundPlus
className="h-design-8 w-design-8 shrink-0"
color="#57B8BF"
/>
<div className="min-w-0 truncate">
{t('gameDesktop.header.register')}
</div>
</button>
</div>
)}
</div>
<div className={'w-full px-design-10 '}>
<div className="flex h-design-29 w-full items-center gap-design-5 my-design-5 rounded-sm border-[#0A353E] border-1">
<div className="flex h-design-19 w-design-43 shrink-0 items-center justify-center gap-design-5 !rounded-[3px] !px-design-6 !py-0">
<Wifi
aria-hidden="true"
color="currentColor"
strokeWidth={2.4}
className={`${signalPresentation.toneClassName} h-design-10 w-design-10 shrink-0`}
/>
<div
className={`${signalPresentation.toneClassName} whitespace-nowrap text-design-7 font-bold leading-none`}
>
{signalPresentation.latencyLabel}
<span className="pl-[1px] text-design-6">ms</span>
</div>
</div>
<div className="flex h-full w-design-66 shrink-0 flex-col items-start justify-center border-r border-[rgba(128,223,231,0.32)] pr-design-7 text-design-7 leading-none">
<div className="text-[#B4E4E9]">
{t('gameDesktop.header.systemTime')}
</div>
<div className="mt-design-3 whitespace-nowrap text-design-7 font-bold text-[#D2FCFF]">
{systemTimeLabel}
</div>
</div>
<button
type="button"
onClick={onOpenRules}
className={`${actionButtonClassName} !px-design-10`}
>
<Info className="h-design-8 w-design-8 shrink-0" color="#57B8BF" />
<div className="min-w-0 truncate">
{t('gameDesktop.header.rules')}
</div>
</button>
<button
type="button"
onClick={onOpenNotice}
className={`${actionButtonClassName} !px-design-10`}
>
<Mail className="h-design-8 w-design-8 shrink-0" color="#57B8BF" />
<div className="min-w-0 truncate">
{t('gameDesktop.header.message')}
</div>
</button>
<button
type="button"
onClick={toggleSoundEnabled}
className={`${actionButtonClassName} !px-design-10`}
>
{isSoundEnabled ? (
<Volume2
className="h-design-8 w-design-8 shrink-0"
color="#57B8BF"
/>
) : (
<VolumeX
className="h-design-8 w-design-8 shrink-0"
color="#57B8BF"
/>
)}
<div className="min-w-0 truncate">
{t('gameDesktop.header.bgm')}
</div>
</button>
<button
type="button"
onClick={onOpenLanguage}
className={`${actionButtonClassName} !px-design-10 justify-between`}
>
<SmartImage
src={currentLanguageOption.icon}
alt={currentLanguageLabel}
className="h-design-14 w-design-14 shrink-0 rounded-full"
imgClassName="object-cover"
/>
<div className="min-w-0 truncate">{currentLanguageLabel}</div>
</button>
</div>
</div>
</header>
)
}

View File

@@ -2,7 +2,6 @@ import { startTransition, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getGameLobbyInit } from '@/features/game' import { getGameLobbyInit } from '@/features/game'
import { EntryNoticeGateModal } from '@/features/game/components'
import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx' import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx'
import { PcEntry } from '@/features/game/entry/pc-entry.tsx' import { PcEntry } from '@/features/game/entry/pc-entry.tsx'
import { useGameRealtimeSync } from '@/features/game/hooks/use-game-realtime-sync.ts' import { useGameRealtimeSync } from '@/features/game/hooks/use-game-realtime-sync.ts'
@@ -147,8 +146,6 @@ export function EntryPage() {
className="flex min-h-0 flex-1 flex-col" className="flex min-h-0 flex-1 flex-col"
> >
{isMobile ? <MobileEntry /> : <PcEntry />} {isMobile ? <MobileEntry /> : <PcEntry />}
<EntryNoticeGateModal />
</section> </section>
) )
} }

View File

@@ -1,7 +1,12 @@
import { useTranslation } from 'react-i18next' import { MobileHeader } from '@/features/game/components/mobile/mobile-header.tsx'
import { useAutoHostingRunner } from '@/features/game/hooks/use-auto-hosting-runner.ts'
export function MobileEntry() { export function MobileEntry() {
const { t } = useTranslation() useAutoHostingRunner()
return <div>{t('gameDesktop.mobile.placeholder')}</div> return (
<>
<MobileHeader />
</>
)
} }

View File

@@ -1,4 +1,4 @@
import { DesktopHeader } from '@/features/game/components' import { DesktopHeader, EntryNoticeGateModal } from '@/features/game/components'
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx' import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx' import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx'
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx' import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
@@ -22,9 +22,7 @@ export function PcEntry() {
<> <>
<DesktopHeader /> <DesktopHeader />
<div <div
className={ className={'mx-auto my-design-10 w-[calc(100%-40*var(--design-unit))]'}
'mx-auto mt-design-20 mb-design-75 w-[calc(100%-40*var(--design-unit))]'
}
> >
<DesktopStatusLine /> <DesktopStatusLine />
</div> </div>
@@ -71,6 +69,8 @@ export function PcEntry() {
<DesktopWithdrawTopupModal /> <DesktopWithdrawTopupModal />
{/* 大奖/小奖动画展示 */} {/* 大奖/小奖动画展示 */}
<DesktopRewardOverlay /> <DesktopRewardOverlay />
{/* 强制弹窗 */}
<EntryNoticeGateModal />
</> </>
) )
} }

View File

@@ -38,14 +38,18 @@ export function useGameHistoryVm() {
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation()
const accessToken = useAuthStore((state) => state.accessToken) const accessToken = useAuthStore((state) => state.accessToken)
const authStatus = useAuthStore((state) => state.status) const authStatus = useAuthStore((state) => state.status)
const roundId = useGameRoundStore((state) => state.round.id) const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const winningCellId = useGameRoundStore((state) => state.round.winningCellId) const revealRoundId = useGameRoundStore(
const lastOpenedRoundRef = useRef<string | null>(null) (state) => state.revealAnimation.roundId,
)
const lastRevealedRoundRef = useRef<string | null>(null)
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: ['game', 'bet-my-orders', accessToken], queryKey: ['game', 'bet-my-orders', accessToken],
enabled: authStatus === 'authenticated' && Boolean(accessToken), enabled: authStatus === 'authenticated' && Boolean(accessToken),
initialPageParam: 1, initialPageParam: 1,
refetchOnMount: 'always',
staleTime: 0,
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
getGameBetMyOrders({ getGameBetMyOrders({
page: pageParam, page: pageParam,
@@ -63,72 +67,62 @@ export function useGameHistoryVm() {
const items = useMemo( const items = useMemo(
() => () =>
(query.data?.pages ?? []).flatMap((page) => (query.data?.pages ?? []).flatMap((page) =>
page.list.map((entry) => ({ page.list.map((entry) => {
amountLabel: entry.total_amount, const shouldHideResult =
createdAtLabel: formatCreatedTime( entry.period_no === revealRoundId && revealPhase !== 'result'
entry.create_time, const resultNumber = shouldHideResult ? null : entry.result_number
i18n.resolvedLanguage ?? 'en-US',
), return {
id: entry.order_no, amountLabel: entry.total_amount,
resultState: createdAtLabel: formatCreatedTime(
entry.result_number === null entry.create_time,
? ('pending' satisfies HistoryResultState) i18n.resolvedLanguage ?? 'en-US',
: entry.numbers.includes(entry.result_number) ),
? ('win' satisfies HistoryResultState) id: entry.order_no,
: ('lost' satisfies HistoryResultState), resultState:
numbersLabel: formatNumbers(entry.numbers), resultNumber === null
numbers: entry.numbers, ? ('pending' satisfies HistoryResultState)
orderNo: entry.order_no, : entry.numbers.includes(resultNumber)
periodNo: entry.period_no, ? ('win' satisfies HistoryResultState)
resultNumberLabel: : ('lost' satisfies HistoryResultState),
entry.result_number === null numbersLabel: formatNumbers(entry.numbers),
? '--' numbers: entry.numbers,
: String(entry.result_number).padStart(2, '0'), orderNo: entry.order_no,
winAmountLabel: entry.win_amount, periodNo: entry.period_no,
})), resultNumberLabel:
resultNumber === null
? '--'
: String(resultNumber).padStart(2, '0'),
winAmountLabel: entry.win_amount,
}
}),
), ),
[i18n.resolvedLanguage, query.data?.pages], [i18n.resolvedLanguage, query.data?.pages, revealPhase, revealRoundId],
) )
useEffect(() => { useEffect(() => {
const openedRoundKey = if (revealPhase !== 'result' || !revealRoundId) {
winningCellId === null || roundId.length === 0
? null
: `${roundId}:${winningCellId}`
if (openedRoundKey === null) {
return return
} }
if (lastOpenedRoundRef.current === null) { if (lastRevealedRoundRef.current === revealRoundId) {
lastOpenedRoundRef.current = openedRoundKey
return return
} }
if (lastOpenedRoundRef.current === openedRoundKey) { if (authStatus !== 'authenticated' || query.isFetching || query.isLoading) {
return return
} }
lastOpenedRoundRef.current = openedRoundKey lastRevealedRoundRef.current = revealRoundId
if (
authStatus !== 'authenticated' ||
items.length >= GAME_HISTORY_PAGE_SIZE ||
query.isFetching ||
query.isLoading
) {
return
}
void query.refetch() void query.refetch()
}, [ }, [
authStatus, authStatus,
items.length,
query.isFetching, query.isFetching,
query.isLoading, query.isLoading,
query.refetch, query.refetch,
roundId, revealPhase,
winningCellId, revealRoundId,
]) ])
return { return {

View File

@@ -492,6 +492,8 @@ function applyPeriodOpenedMessage(
message: GameSocketMessage, message: GameSocketMessage,
serverTime: number | null, serverTime: number | null,
) { ) {
console.log('%c[period.opened 开奖数据]', 'color: red;', message)
applyPeriodMessage(message, serverTime) applyPeriodMessage(message, serverTime)
const period = extractPeriodEventData(message) const period = extractPeriodEventData(message)
@@ -580,6 +582,8 @@ function applyWalletChangedMessage(message: GameSocketMessage) {
} }
function applyJackpotHitMessage(message: GameSocketMessage) { function applyJackpotHitMessage(message: GameSocketMessage) {
console.log('%c[jackpot.hit 数据]', 'color: red;', message)
const jackpotHitData = extractJackpotHitData(message) const jackpotHitData = extractJackpotHitData(message)
if (jackpotHitData?.hits.length) { if (jackpotHitData?.hits.length) {

View File

@@ -1,7 +1,9 @@
import { useMutation } from '@tanstack/react-query'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { import {
CircleUserRound, CircleUserRound,
ClipboardList, ClipboardList,
LogOut,
ReceiptText, ReceiptText,
WalletCards, WalletCards,
} from 'lucide-react' } from 'lucide-react'
@@ -13,8 +15,10 @@ import userInfoBg from '@/assets/system/userInfo-bg.webp'
import { CenterModal } from '@/components/center-modal.tsx' import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx' import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx' import { SmartImage } from '@/components/smart-image.tsx'
import { logoutWithPassword } from '@/features/auth/api/auth-api'
import DesktopFinanceRecordsTab from '@/features/game/modal/desktop/desktop-finance-records-tab' import DesktopFinanceRecordsTab from '@/features/game/modal/desktop/desktop-finance-records-tab'
import DesktopWalletRecordsTab from '@/features/game/modal/desktop/desktop-wallet-records-tab' import DesktopWalletRecordsTab from '@/features/game/modal/desktop/desktop-wallet-records-tab'
import { clearAuthenticatedSession } from '@/lib/auth/auth-session'
import { notify } from '@/lib/notify' import { notify } from '@/lib/notify'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuthStore, useModalStore } from '@/store' import { useAuthStore, useModalStore } from '@/store'
@@ -88,6 +92,11 @@ function DesktopUserInfoModal() {
const [activeTab, setActiveTab] = useState<UserInfoTabKey>('profile') const [activeTab, setActiveTab] = useState<UserInfoTabKey>('profile')
const currentUser = useAuthStore((state) => state.currentUser) const currentUser = useAuthStore((state) => state.currentUser)
const inviteCode = currentUser?.registerInviteCode?.trim() ?? '' const inviteCode = currentUser?.registerInviteCode?.trim() ?? ''
const logoutUsername =
currentUser?.username ?? currentUser?.phone ?? currentUser?.name ?? ''
const logoutMutation = useMutation({
mutationFn: logoutWithPassword,
})
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -112,6 +121,25 @@ function DesktopUserInfoModal() {
} }
} }
async function handleLogout() {
if (logoutMutation.isPending) {
return
}
try {
await logoutMutation.mutateAsync({
password: '',
username: logoutUsername,
})
notify.success(t('commonUi.toast.logoutSuccess'))
} catch {
notify.warning(t('commonUi.toast.logoutLocalOnly'))
} finally {
clearAuthenticatedSession({ clearBrowserStorage: true })
setModalOpen('desktopUserInfo', false)
}
}
return ( return (
<CenterModal <CenterModal
open={open} open={open}
@@ -273,6 +301,24 @@ function DesktopUserInfoModal() {
</button> </button>
</div> </div>
</div> </div>
<div className={'w-full flex justify-end'}>
<button
type="button"
onClick={() => {
void handleLogout()
}}
disabled={logoutMutation.isPending}
className="mt-auto inline-flex h-design-44 min-w-design-170 cursor-pointer items-center justify-center gap-design-10 rounded-md border border-[#8F4747] bg-[#3A1111]/80 px-design-18 text-design-18 text-[#FFD7D7] transition-colors duration-200 hover:border-[#FF8A8A] hover:bg-[#5A1818]/85 hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#FF8A8A] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60"
>
<LogOut className="h-design-20 w-design-20" />
<span>
{logoutMutation.isPending
? t('game.modals.userInfo.profile.loggingOut')
: t('game.modals.userInfo.profile.logout')}
</span>
</button>
</div>
</SmartBackground> </SmartBackground>
) : activeTab === 'financeRecords' ? ( ) : activeTab === 'financeRecords' ? (
<DesktopFinanceRecordsTab <DesktopFinanceRecordsTab

View File

@@ -2,6 +2,7 @@ import ky, { HTTPError, type Options, TimeoutError } from 'ky'
import { import {
ACCESS_TOKEN_REFRESH_SKEW_MS, ACCESS_TOKEN_REFRESH_SKEW_MS,
API_ERROR_MESSAGES, API_ERROR_MESSAGES,
AUTH_INVALID_TOKEN_CODE,
AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY, AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY,
AUTH_REFRESH_ENDPOINT, AUTH_REFRESH_ENDPOINT,
AUTH_SKIP_REFRESH_CONTEXT_KEY, AUTH_SKIP_REFRESH_CONTEXT_KEY,
@@ -14,6 +15,7 @@ import type { AuthTokenDto } from '@/features/auth/api/types'
import { getPreferredLanguage, isSupportedLanguage } from '@/i18n' import { getPreferredLanguage, isSupportedLanguage } from '@/i18n'
import { ApiError } from '@/lib/api/api-error.ts' import { ApiError } from '@/lib/api/api-error.ts'
import { import {
handleInvalidTokenSession,
handleUnauthorizedSession, handleUnauthorizedSession,
tryRefreshAuthSession, tryRefreshAuthSession,
} from '@/lib/auth/auth-session' } from '@/lib/auth/auth-session'
@@ -90,6 +92,10 @@ function getErrorMessage(response: Response, data: unknown) {
} }
async function toApiError(error: unknown) { async function toApiError(error: unknown) {
if (error instanceof ApiError) {
return error
}
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
const data = error.data const data = error.data
@@ -186,7 +192,40 @@ function shouldTryRefreshAccessToken(input: string, options?: Options) {
) )
} }
function isApiEnvelope(value: unknown): value is ApiResponse<unknown> {
return Boolean(
value &&
typeof value === 'object' &&
'code' in value &&
typeof value.code === 'number',
)
}
function getApiEnvelopeMessage(response: ApiResponse<unknown>) {
return 'msg' in response && typeof response.msg === 'string'
? response.msg
: 'message' in response && typeof response.message === 'string'
? response.message
: API_ERROR_MESSAGES.unexpected
}
function assertValidAuthEnvelope(data: unknown) {
if (!isApiEnvelope(data) || data.code !== AUTH_INVALID_TOKEN_CODE) {
return
}
handleInvalidTokenSession()
throw new ApiError({
data,
message: getApiEnvelopeMessage(data),
status: 401,
})
}
function unwrapEnvelopeData<T>(response: ApiResponse<T>) { function unwrapEnvelopeData<T>(response: ApiResponse<T>) {
assertValidAuthEnvelope(response)
if (response.code === 1) { if (response.code === 1) {
return response.data return response.data
} }
@@ -313,6 +352,8 @@ async function request<TResponse>(input: string, options?: Options) {
) )
const data = await parseResponseBody(response) const data = await parseResponseBody(response)
assertValidAuthEnvelope(data)
return data as TResponse return data as TResponse
} catch (error) { } catch (error) {
if ( if (

View File

@@ -1,5 +1,9 @@
import i18n from '@/i18n'
import { notify } from '@/lib/notify'
import { queryClient } from '@/lib/query/query-client'
import type { AuthSessionInput, AuthUser } from '@/store/auth' import type { AuthSessionInput, AuthUser } from '@/store/auth'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useModalStore } from '@/store/modal'
export type CurrentUserInitializer = () => Promise<AuthUser | null> export type CurrentUserInitializer = () => Promise<AuthUser | null>
export type RefreshSessionHandler = ( export type RefreshSessionHandler = (
@@ -10,6 +14,28 @@ let currentUserInitializer: CurrentUserInitializer | null = null
let refreshSessionHandler: RefreshSessionHandler | null = null let refreshSessionHandler: RefreshSessionHandler | null = null
let authInitializationPromise: Promise<void> | null = null let authInitializationPromise: Promise<void> | null = null
let refreshSessionPromise: Promise<boolean> | null = null let refreshSessionPromise: Promise<boolean> | null = null
let lastLoginPromptAt = 0
const LOGIN_PROMPT_DEDUP_MS = 1200
interface ClearAuthenticatedSessionOptions {
clearBrowserStorage?: boolean
}
interface UnauthorizedSessionOptions extends ClearAuthenticatedSessionOptions {
openLoginModal?: boolean
showLoginRequiredToast?: boolean
}
function clearBrowserStorageData() {
if (typeof localStorage !== 'undefined') {
localStorage.clear()
}
if (typeof sessionStorage !== 'undefined') {
sessionStorage.clear()
}
}
export function registerCurrentUserInitializer( export function registerCurrentUserInitializer(
initializer: CurrentUserInitializer | null, initializer: CurrentUserInitializer | null,
@@ -29,8 +55,55 @@ export function isAuthenticated() {
return snapshot.status === 'authenticated' && Boolean(snapshot.accessToken) return snapshot.status === 'authenticated' && Boolean(snapshot.accessToken)
} }
export function handleUnauthorizedSession() { export function clearAuthenticatedSession({
clearBrowserStorage = true,
}: ClearAuthenticatedSessionOptions = {}) {
useAuthStore.getState().markUnauthorized() useAuthStore.getState().markUnauthorized()
queryClient.clear()
if (clearBrowserStorage) {
clearBrowserStorageData()
}
}
export function handleUnauthorizedSession({
clearBrowserStorage = false,
openLoginModal = false,
showLoginRequiredToast = false,
}: UnauthorizedSessionOptions = {}) {
clearAuthenticatedSession({ clearBrowserStorage })
if (!openLoginModal && !showLoginRequiredToast) {
return
}
const now = Date.now()
const shouldPrompt = now - lastLoginPromptAt > LOGIN_PROMPT_DEDUP_MS
if (!shouldPrompt) {
return
}
lastLoginPromptAt = now
if (showLoginRequiredToast) {
notify.warning(i18n.t('commonUi.toast.loginRequired'))
}
if (openLoginModal) {
const modalStore = useModalStore.getState()
modalStore.closeAllModals()
modalStore.setModalOpen('desktopLogin', true)
}
}
export function handleInvalidTokenSession() {
handleUnauthorizedSession({
clearBrowserStorage: true,
openLoginModal: true,
showLoginRequiredToast: true,
})
} }
export async function initializeAuthSession() { export async function initializeAuthSession() {

View File

@@ -160,6 +160,8 @@ export default {
tel: 'Phone', tel: 'Phone',
registeredAt: 'Registered at', registeredAt: 'Registered at',
copyInviteLink: 'Copy invite link', copyInviteLink: 'Copy invite link',
logout: 'Log out',
loggingOut: 'Logging out...',
signature: signature:
'My signature is as unique as my personality. This area will later display the real profile summary.', 'My signature is as unique as my personality. This area will later display the real profile summary.',
}, },
@@ -244,6 +246,8 @@ export default {
lobbyInitFailed: 'Failed to load the game lobby', lobbyInitFailed: 'Failed to load the game lobby',
loginRequired: 'Please log in before entering the game', loginRequired: 'Please log in before entering the game',
loginSuccess: 'Login successful', loginSuccess: 'Login successful',
logoutSuccess: 'Logged out',
logoutLocalOnly: 'Logout request failed. Local session was cleared.',
registerSuccess: 'Registration successful', registerSuccess: 'Registration successful',
inviteLinkCopied: 'Invite link copied', inviteLinkCopied: 'Invite link copied',
inviteLinkCopyFailed: inviteLinkCopyFailed:

View File

@@ -159,6 +159,8 @@ export default {
tel: 'Telepon', tel: 'Telepon',
registeredAt: 'Tanggal daftar', registeredAt: 'Tanggal daftar',
copyInviteLink: 'Salin tautan undangan', copyInviteLink: 'Salin tautan undangan',
logout: 'Keluar',
loggingOut: 'Keluar...',
signature: signature:
'Tanda tanganku seunik diriku. Bagian ini nantinya akan menampilkan ringkasan profil yang sebenarnya.', 'Tanda tanganku seunik diriku. Bagian ini nantinya akan menampilkan ringkasan profil yang sebenarnya.',
}, },
@@ -243,6 +245,8 @@ export default {
lobbyInitFailed: 'Gagal memuat lobby game', lobbyInitFailed: 'Gagal memuat lobby game',
loginRequired: 'Silakan masuk sebelum memasuki game', loginRequired: 'Silakan masuk sebelum memasuki game',
loginSuccess: 'Berhasil masuk', loginSuccess: 'Berhasil masuk',
logoutSuccess: 'Berhasil keluar',
logoutLocalOnly: 'Permintaan keluar gagal. Sesi lokal telah dibersihkan.',
registerSuccess: 'Pendaftaran berhasil', registerSuccess: 'Pendaftaran berhasil',
inviteLinkCopied: 'Tautan undangan disalin', inviteLinkCopied: 'Tautan undangan disalin',
inviteLinkCopyFailed: inviteLinkCopyFailed:
@@ -362,7 +366,7 @@ export default {
message: 'Pesan', message: 'Pesan',
bgm: 'BGM', bgm: 'BGM',
id: 'ID', id: 'ID',
fullscreen: 'Layar Penuh', fullscreen: 'Layar',
login: 'Masuk', login: 'Masuk',
register: 'Daftar', register: 'Daftar',
}, },

View File

@@ -162,6 +162,8 @@ export default {
tel: 'Telefon', tel: 'Telefon',
registeredAt: 'Tarikh daftar', registeredAt: 'Tarikh daftar',
copyInviteLink: 'Salin pautan jemputan', copyInviteLink: 'Salin pautan jemputan',
logout: 'Log Keluar',
loggingOut: 'Sedang log keluar...',
signature: signature:
'Tandatangan saya unik seperti personaliti saya. Bahagian ini akan memaparkan ringkasan profil sebenar kemudian.', 'Tandatangan saya unik seperti personaliti saya. Bahagian ini akan memaparkan ringkasan profil sebenar kemudian.',
}, },
@@ -246,6 +248,9 @@ export default {
lobbyInitFailed: 'Gagal memuatkan lobi permainan', lobbyInitFailed: 'Gagal memuatkan lobi permainan',
loginRequired: 'Sila log masuk sebelum memasuki permainan', loginRequired: 'Sila log masuk sebelum memasuki permainan',
loginSuccess: 'Log masuk berjaya', loginSuccess: 'Log masuk berjaya',
logoutSuccess: 'Telah log keluar',
logoutLocalOnly:
'Permintaan log keluar gagal. Sesi tempatan telah dikosongkan.',
registerSuccess: 'Pendaftaran berjaya', registerSuccess: 'Pendaftaran berjaya',
inviteLinkCopied: 'Pautan jemputan telah disalin', inviteLinkCopied: 'Pautan jemputan telah disalin',
inviteLinkCopyFailed: inviteLinkCopyFailed:
@@ -366,7 +371,7 @@ export default {
message: 'Mesej', message: 'Mesej',
bgm: 'BGM', bgm: 'BGM',
id: 'ID', id: 'ID',
fullscreen: 'Skrin Penuh', fullscreen: 'Skrin',
login: 'Log Masuk', login: 'Log Masuk',
register: 'Daftar', register: 'Daftar',
}, },

View File

@@ -157,6 +157,8 @@ export default {
tel: '电话', tel: '电话',
registeredAt: '注册时间', registeredAt: '注册时间',
copyInviteLink: '复制邀请链接', copyInviteLink: '复制邀请链接',
logout: '退出登录',
loggingOut: '退出中...',
signature: '我的个性签名和灵魂一样独特,后续这里会接真实资料字段。', signature: '我的个性签名和灵魂一样独特,后续这里会接真实资料字段。',
}, },
message: { message: {
@@ -238,6 +240,8 @@ export default {
lobbyInitFailed: '游戏大厅加载失败', lobbyInitFailed: '游戏大厅加载失败',
loginRequired: '请先登录后进入游戏', loginRequired: '请先登录后进入游戏',
loginSuccess: '登录成功', loginSuccess: '登录成功',
logoutSuccess: '已退出登录',
logoutLocalOnly: '退出接口请求失败,已清除本地登录状态',
registerSuccess: '注册成功', registerSuccess: '注册成功',
inviteLinkCopied: '邀请链接已复制', inviteLinkCopied: '邀请链接已复制',
inviteLinkCopyFailed: '邀请链接复制失败,请手动复制', inviteLinkCopyFailed: '邀请链接复制失败,请手动复制',

View File

@@ -5,7 +5,6 @@ import { tanstackRouter } from '@tanstack/router-plugin/vite'
import react, { reactCompilerPreset } from '@vitejs/plugin-react' import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
@@ -32,4 +31,7 @@ export default defineConfig({
react(), react(),
babel({ presets: [reactCompilerPreset()] }), babel({ presets: [reactCompilerPreset()] }),
], ],
build: {
outDir: 'zihua-web',
},
}) })