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_AUTH_TOKEN_SECRET=564d14asdasd113e46542asd6das1a2a

2
.gitignore vendored
View File

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

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# 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.

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# 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.

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

View File

@@ -9,6 +9,8 @@ import type {
AuthUserProfileDto,
LoginPayload,
LoginRequestDto,
LogoutPayload,
LogoutRequestDto,
RefreshTokenDto,
RefreshTokenRequestDto,
RegisterPayload,
@@ -107,6 +109,25 @@ export async function loginWithPassword(
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(
payload: RegisterPayload,
): Promise<AuthSessionInput> {

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,8 @@
import { useTranslation } from 'react-i18next'
import broadcast from '@/assets/system/broadcast.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import { useGameSessionStore } from '@/store/game'
export function DesktopTitle() {
const { t } = useTranslation()
const jackpotBroadcasts = useGameSessionStore(
(state) => state.jackpotBroadcasts,
)
@@ -30,9 +28,6 @@ export function DesktopTitle() {
alt={'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={

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

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
import { useMutation } from '@tanstack/react-query'
import dayjs from 'dayjs'
import {
CircleUserRound,
ClipboardList,
LogOut,
ReceiptText,
WalletCards,
} from 'lucide-react'
@@ -13,8 +15,10 @@ import userInfoBg from '@/assets/system/userInfo-bg.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.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 DesktopWalletRecordsTab from '@/features/game/modal/desktop/desktop-wallet-records-tab'
import { clearAuthenticatedSession } from '@/lib/auth/auth-session'
import { notify } from '@/lib/notify'
import { cn } from '@/lib/utils'
import { useAuthStore, useModalStore } from '@/store'
@@ -88,6 +92,11 @@ function DesktopUserInfoModal() {
const [activeTab, setActiveTab] = useState<UserInfoTabKey>('profile')
const currentUser = useAuthStore((state) => state.currentUser)
const inviteCode = currentUser?.registerInviteCode?.trim() ?? ''
const logoutUsername =
currentUser?.username ?? currentUser?.phone ?? currentUser?.name ?? ''
const logoutMutation = useMutation({
mutationFn: logoutWithPassword,
})
useEffect(() => {
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 (
<CenterModal
open={open}
@@ -273,6 +301,24 @@ function DesktopUserInfoModal() {
</button>
</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>
) : activeTab === 'financeRecords' ? (
<DesktopFinanceRecordsTab

View File

@@ -2,6 +2,7 @@ import ky, { HTTPError, type Options, TimeoutError } from 'ky'
import {
ACCESS_TOKEN_REFRESH_SKEW_MS,
API_ERROR_MESSAGES,
AUTH_INVALID_TOKEN_CODE,
AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY,
AUTH_REFRESH_ENDPOINT,
AUTH_SKIP_REFRESH_CONTEXT_KEY,
@@ -14,6 +15,7 @@ import type { AuthTokenDto } from '@/features/auth/api/types'
import { getPreferredLanguage, isSupportedLanguage } from '@/i18n'
import { ApiError } from '@/lib/api/api-error.ts'
import {
handleInvalidTokenSession,
handleUnauthorizedSession,
tryRefreshAuthSession,
} from '@/lib/auth/auth-session'
@@ -90,6 +92,10 @@ function getErrorMessage(response: Response, data: unknown) {
}
async function toApiError(error: unknown) {
if (error instanceof ApiError) {
return error
}
if (error instanceof HTTPError) {
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>) {
assertValidAuthEnvelope(response)
if (response.code === 1) {
return response.data
}
@@ -313,6 +352,8 @@ async function request<TResponse>(input: string, options?: Options) {
)
const data = await parseResponseBody(response)
assertValidAuthEnvelope(data)
return data as TResponse
} catch (error) {
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 { useAuthStore } from '@/store/auth'
import { useModalStore } from '@/store/modal'
export type CurrentUserInitializer = () => Promise<AuthUser | null>
export type RefreshSessionHandler = (
@@ -10,6 +14,28 @@ let currentUserInitializer: CurrentUserInitializer | null = null
let refreshSessionHandler: RefreshSessionHandler | null = null
let authInitializationPromise: Promise<void> | 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(
initializer: CurrentUserInitializer | null,
@@ -29,8 +55,55 @@ export function isAuthenticated() {
return snapshot.status === 'authenticated' && Boolean(snapshot.accessToken)
}
export function handleUnauthorizedSession() {
export function clearAuthenticatedSession({
clearBrowserStorage = true,
}: ClearAuthenticatedSessionOptions = {}) {
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() {

View File

@@ -160,6 +160,8 @@ export default {
tel: 'Phone',
registeredAt: 'Registered at',
copyInviteLink: 'Copy invite link',
logout: 'Log out',
loggingOut: 'Logging out...',
signature:
'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',
loginRequired: 'Please log in before entering the game',
loginSuccess: 'Login successful',
logoutSuccess: 'Logged out',
logoutLocalOnly: 'Logout request failed. Local session was cleared.',
registerSuccess: 'Registration successful',
inviteLinkCopied: 'Invite link copied',
inviteLinkCopyFailed:

View File

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

View File

@@ -162,6 +162,8 @@ export default {
tel: 'Telefon',
registeredAt: 'Tarikh daftar',
copyInviteLink: 'Salin pautan jemputan',
logout: 'Log Keluar',
loggingOut: 'Sedang log keluar...',
signature:
'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',
loginRequired: 'Sila log masuk sebelum memasuki permainan',
loginSuccess: 'Log masuk berjaya',
logoutSuccess: 'Telah log keluar',
logoutLocalOnly:
'Permintaan log keluar gagal. Sesi tempatan telah dikosongkan.',
registerSuccess: 'Pendaftaran berjaya',
inviteLinkCopied: 'Pautan jemputan telah disalin',
inviteLinkCopyFailed:
@@ -366,7 +371,7 @@ export default {
message: 'Mesej',
bgm: 'BGM',
id: 'ID',
fullscreen: 'Skrin Penuh',
fullscreen: 'Skrin',
login: 'Log Masuk',
register: 'Daftar',
},

View File

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

View File

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