feat(auth): 添加登出功能并优化认证处理
- 添加了登出相关的API端点和常量定义 - 实现了登出功能及密码验证登出逻辑 - 添加了登出会话清理和浏览器存储清除 - 在用户信息模态框中集成了登出按钮 - 添加了登出相关的国际化翻译 - 优化了API客户端中的认证错误处理 - 实现了无效令牌的自动处理机制 - 更新了GitNexus索引统计数据 - 修改了构建输出目录配置 - 清理了不必要的注释和代码 - 调整了移动端头部组件结构 - 优化了游戏历史记录查询逻辑 - 添加了控制台日志用于调试 - 设置默认注册邀请码为D97DBC16 - 在.gitignore中添加构建产物忽略规则
This commit is contained in:
@@ -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
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
BIN
figma/img.png
BIN
figma/img.png
Binary file not shown.
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 102 KiB |
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
223
src/features/game/components/mobile/mobile-header.tsx
Normal file
223
src/features/game/components/mobile/mobile-header.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: '邀请链接复制失败,请手动复制',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user