feat(game): 更新游戏页面功能和认证刷新机制
- 将开发环境代理目标从 192.168.1.5 改为 127.0.0.1 - 重构 auth.ts 文件中的代码缩进格式 - 实现自动令牌刷新机制,支持 JWT 过期时间检测 - 添加 WebSocket 连接的令牌强制刷新逻辑 - 新增 WindSquare 组件显示方位风向图标 - 实现动态座位风向计算和显示功能 - 优化 WebSocket URL 构建方式,移除查询参数中的令牌传递 - 添加登录失效时自动跳转到登录页面的功能 - 限制玩家名称显示长度为4个字符 - 改进 WebSocket 错误处理和重连机制
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
VITE_API_BASE_URL=/api/v1
|
VITE_API_BASE_URL=/api/v1
|
||||||
VITE_GAME_WS_URL=/ws
|
VITE_GAME_WS_URL=/ws
|
||||||
VITE_API_PROXY_TARGET=http://192.168.1.5:19000
|
VITE_API_PROXY_TARGET=http://127.0.0.1:19000
|
||||||
VITE_WS_PROXY_TARGET=http://192.168.1.5:19000
|
VITE_WS_PROXY_TARGET=http://127.0.0.1:19000
|
||||||
|
|||||||
263
src/api/auth.ts
263
src/api/auth.ts
@@ -1,26 +1,26 @@
|
|||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id?: string | number
|
id?: string | number
|
||||||
username?: string
|
username?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthSessionInput {
|
export interface AuthSessionInput {
|
||||||
token: string
|
token: string
|
||||||
tokenType?: string
|
tokenType?: string
|
||||||
refreshToken?: string
|
refreshToken?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResult {
|
export interface AuthResult {
|
||||||
token: string
|
token: string
|
||||||
tokenType?: string
|
tokenType?: string
|
||||||
refreshToken?: string
|
refreshToken?: string
|
||||||
expiresIn?: number
|
expiresIn?: number
|
||||||
user?: AuthUser
|
user?: AuthUser
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiErrorPayload {
|
interface ApiErrorPayload {
|
||||||
message?: string
|
message?: string
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').trim().replace(/\/$/, '')
|
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').trim().replace(/\/$/, '')
|
||||||
@@ -30,165 +30,178 @@ const REFRESH_PATH = import.meta.env.VITE_REFRESH_PATH ?? '/api/v1/auth/refresh'
|
|||||||
const LOGIN_BEARER_TOKEN = (import.meta.env.VITE_LOGIN_BEARER_TOKEN ?? '').trim()
|
const LOGIN_BEARER_TOKEN = (import.meta.env.VITE_LOGIN_BEARER_TOKEN ?? '').trim()
|
||||||
|
|
||||||
function buildUrl(path: string): string {
|
function buildUrl(path: string): string {
|
||||||
if (/^https?:\/\//.test(path)) {
|
if (/^https?:\/\//.test(path)) {
|
||||||
return path
|
return path
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
|
||||||
if (!API_BASE_URL) {
|
|
||||||
return normalizedPath
|
|
||||||
}
|
|
||||||
|
|
||||||
if (API_BASE_URL.startsWith('/')) {
|
|
||||||
const basePath = API_BASE_URL.startsWith('/') ? API_BASE_URL : `/${API_BASE_URL}`
|
|
||||||
if (normalizedPath === basePath || normalizedPath.startsWith(`${basePath}/`)) {
|
|
||||||
return normalizedPath
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${basePath}${normalizedPath}`
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||||
}
|
if (!API_BASE_URL) {
|
||||||
|
return normalizedPath
|
||||||
// Avoid duplicated API prefix, e.g. base: /api/v1 + path: /api/v1/auth/login
|
|
||||||
try {
|
|
||||||
const baseUrl = new URL(API_BASE_URL)
|
|
||||||
const basePath = baseUrl.pathname.replace(/\/$/, '')
|
|
||||||
if (basePath && normalizedPath.startsWith(`${basePath}/`)) {
|
|
||||||
return `${API_BASE_URL}${normalizedPath.slice(basePath.length)}`
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// API_BASE_URL may be a relative path; fallback to direct join.
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${API_BASE_URL}${normalizedPath}`
|
if (API_BASE_URL.startsWith('/')) {
|
||||||
|
const basePath = API_BASE_URL.startsWith('/') ? API_BASE_URL : `/${API_BASE_URL}`
|
||||||
|
if (normalizedPath === basePath || normalizedPath.startsWith(`${basePath}/`)) {
|
||||||
|
return normalizedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${basePath}${normalizedPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid duplicated API prefix, e.g. base: /api/v1 + path: /api/v1/auth/login
|
||||||
|
try {
|
||||||
|
const baseUrl = new URL(API_BASE_URL)
|
||||||
|
const basePath = baseUrl.pathname.replace(/\/$/, '')
|
||||||
|
if (basePath && normalizedPath.startsWith(`${basePath}/`)) {
|
||||||
|
return `${API_BASE_URL}${normalizedPath.slice(basePath.length)}`
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// API_BASE_URL may be a relative path; fallback to direct join.
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${API_BASE_URL}${normalizedPath}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
url: string,
|
url: string,
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
extraHeaders?: Record<string, string>,
|
extraHeaders?: Record<string, string>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...extraHeaders,
|
...extraHeaders,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|
||||||
const payload = (await response.json().catch(() => ({}))) as T & ApiErrorPayload
|
const payload = (await response.json().catch(() => ({}))) as T & ApiErrorPayload
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(payload.message ?? payload.error ?? '请求失败,请稍后再试')
|
throw new Error(payload.message ?? payload.error ?? '请求失败,请稍后再试')
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAuthHeader(token: string, tokenType = 'Bearer'): string {
|
function createAuthHeader(token: string, tokenType = 'Bearer'): string {
|
||||||
const normalizedToken = token.trim()
|
const normalizedToken = token.trim()
|
||||||
if (/^\S+\s+\S+/.test(normalizedToken)) {
|
if (/^\S+\s+\S+/.test(normalizedToken)) {
|
||||||
return normalizedToken
|
return normalizedToken
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${tokenType || 'Bearer'} ${normalizedToken}`
|
return `${tokenType || 'Bearer'} ${normalizedToken}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractToken(payload: Record<string, unknown>): string {
|
function extractToken(payload: Record<string, unknown>): string {
|
||||||
const candidate =
|
const candidate =
|
||||||
payload.token ??
|
payload.token ??
|
||||||
payload.accessToken ??
|
payload.accessToken ??
|
||||||
payload.access_token ??
|
payload.access_token ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.token ??
|
(payload.data as Record<string, unknown> | undefined)?.token ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.accessToken ??
|
(payload.data as Record<string, unknown> | undefined)?.accessToken ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.access_token
|
(payload.data as Record<string, unknown> | undefined)?.access_token
|
||||||
|
|
||||||
if (typeof candidate !== 'string' || candidate.length === 0) {
|
if (typeof candidate !== 'string' || candidate.length === 0) {
|
||||||
throw new Error('登录成功,但后端未返回 token 字段')
|
throw new Error('登录成功,但后端未返回 token 字段')
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTokenType(payload: Record<string, unknown>): string | undefined {
|
function extractTokenType(payload: Record<string, unknown>): string | undefined {
|
||||||
const candidate =
|
const candidate =
|
||||||
payload.token_type ??
|
payload.token_type ??
|
||||||
payload.tokenType ??
|
payload.tokenType ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.token_type ??
|
(payload.data as Record<string, unknown> | undefined)?.token_type ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.tokenType
|
(payload.data as Record<string, unknown> | undefined)?.tokenType
|
||||||
|
|
||||||
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
|
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractRefreshToken(payload: Record<string, unknown>): string | undefined {
|
function extractRefreshToken(payload: Record<string, unknown>): string | undefined {
|
||||||
const candidate =
|
const candidate =
|
||||||
payload.refresh_token ??
|
payload.refresh_token ??
|
||||||
payload.refreshToken ??
|
payload.refreshToken ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.refresh_token ??
|
(payload.data as Record<string, unknown> | undefined)?.refresh_token ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.refreshToken
|
(payload.data as Record<string, unknown> | undefined)?.refreshToken
|
||||||
|
|
||||||
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
|
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractExpiresIn(payload: Record<string, unknown>): number | undefined {
|
function extractExpiresIn(payload: Record<string, unknown>): number | undefined {
|
||||||
const candidate =
|
const candidate =
|
||||||
payload.expires_in ??
|
payload.expires_in ??
|
||||||
payload.expiresIn ??
|
payload.expiresIn ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.expires_in ??
|
(payload.data as Record<string, unknown> | undefined)?.expires_in ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.expiresIn
|
(payload.data as Record<string, unknown> | undefined)?.expiresIn
|
||||||
|
|
||||||
return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined
|
return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractUser(payload: Record<string, unknown>): AuthUser | undefined {
|
function extractUser(payload: Record<string, unknown>): AuthUser | undefined {
|
||||||
const user = payload.user ?? (payload.data as Record<string, unknown> | undefined)?.user
|
const user = payload.user ?? (payload.data as Record<string, unknown> | undefined)?.user
|
||||||
return typeof user === 'object' && user !== null ? (user as AuthUser) : undefined
|
return typeof user === 'object' && user !== null ? (user as AuthUser) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAuthResult(payload: Record<string, unknown>): AuthResult {
|
function parseAuthResult(payload: Record<string, unknown>): AuthResult {
|
||||||
return {
|
return {
|
||||||
token: extractToken(payload),
|
token: extractToken(payload),
|
||||||
tokenType: extractTokenType(payload),
|
tokenType: extractTokenType(payload),
|
||||||
refreshToken: extractRefreshToken(payload),
|
refreshToken: extractRefreshToken(payload),
|
||||||
expiresIn: extractExpiresIn(payload),
|
expiresIn: extractExpiresIn(payload),
|
||||||
user: extractUser(payload),
|
user: extractUser(payload),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function register(input: {
|
export async function register(input: {
|
||||||
username: string
|
username: string
|
||||||
phone: string
|
phone: string
|
||||||
email: string
|
email: string
|
||||||
password: string
|
password: string
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await request<Record<string, unknown>>(buildUrl(REGISTER_PATH), input)
|
await request<Record<string, unknown>>(buildUrl(REGISTER_PATH), input)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(input: { loginId: string; password: string }): Promise<AuthResult> {
|
export async function login(input: { loginId: string; password: string }): Promise<AuthResult> {
|
||||||
const payload = await request<Record<string, unknown>>(
|
const payload = await request<Record<string, unknown>>(
|
||||||
buildUrl(LOGIN_PATH),
|
buildUrl(LOGIN_PATH),
|
||||||
{
|
{
|
||||||
login_id: input.loginId,
|
login_id: input.loginId,
|
||||||
password: input.password,
|
password: input.password,
|
||||||
},
|
},
|
||||||
LOGIN_BEARER_TOKEN ? { Authorization: `Bearer ${LOGIN_BEARER_TOKEN}` } : undefined,
|
LOGIN_BEARER_TOKEN ? {Authorization: `Bearer ${LOGIN_BEARER_TOKEN}`} : undefined,
|
||||||
)
|
)
|
||||||
return parseAuthResult(payload)
|
return parseAuthResult(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshAccessToken(input: AuthSessionInput): Promise<AuthResult> {
|
export async function refreshAccessToken(input: AuthSessionInput): Promise<AuthResult> {
|
||||||
if (!input.refreshToken) {
|
if (!input.refreshToken) {
|
||||||
throw new Error('缺少 refresh_token,无法刷新登录状态')
|
throw new Error('缺少 refresh_token,无法刷新登录状态')
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await request<Record<string, unknown>>(
|
const refreshBody = {
|
||||||
buildUrl(REFRESH_PATH),
|
refreshToken: input.refreshToken
|
||||||
{
|
}
|
||||||
refreshToken: input.refreshToken,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Authorization: createAuthHeader(input.token, input.tokenType),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return parseAuthResult(payload)
|
// 兼容不同后端实现:
|
||||||
|
// 1) 有的要求 Authorization + refresh token
|
||||||
|
// 2) 有的只接受 refresh token,不接受 Authorization
|
||||||
|
let payload: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
payload = await request<Record<string, unknown>>(
|
||||||
|
buildUrl(REFRESH_PATH),
|
||||||
|
refreshBody,
|
||||||
|
{
|
||||||
|
Authorization: createAuthHeader(input.token, input.tokenType),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
payload = await request<Record<string, unknown>>(
|
||||||
|
buildUrl(REFRESH_PATH),
|
||||||
|
refreshBody,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseAuthResult(payload)
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
src/assets/images/direction/bei.png
Executable file
BIN
src/assets/images/direction/bei.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src/assets/images/direction/dong.png
Executable file
BIN
src/assets/images/direction/dong.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
BIN
src/assets/images/direction/nan.png
Executable file
BIN
src/assets/images/direction/nan.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
BIN
src/assets/images/direction/xi.png
Executable file
BIN
src/assets/images/direction/xi.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
1
src/assets/images/icons/triangle.svg
Normal file
1
src/assets/images/icons/triangle.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1774491457300" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6759" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M535.466667 812.8l450.133333-563.2c14.933333-19.2 2.133333-49.066667-23.466667-49.066667H61.866667c-25.6 0-38.4 29.866667-23.466667 49.066667l450.133333 563.2c12.8 14.933333 34.133333 14.933333 46.933334 0z" fill="#ffffff" p-id="6760"></path></svg>
|
||||||
|
After Width: | Height: | Size: 581 B |
@@ -746,225 +746,6 @@ button:disabled {
|
|||||||
border-style: solid;
|
border-style: solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-watermark {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 24px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
color: rgba(244, 240, 220, 0.82);
|
|
||||||
text-align: center;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-watermark span {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #f7e4b0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-watermark strong {
|
|
||||||
font-size: 26px;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-watermark small {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #bdd8ca;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-badge {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 154px;
|
|
||||||
padding: 9px 12px;
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px solid rgba(248, 226, 173, 0.24);
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(43, 52, 73, 0.84), rgba(17, 22, 34, 0.82)),
|
|
||||||
radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 40%);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
|
||||||
0 12px 28px rgba(0, 0, 0, 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-panel {
|
|
||||||
position: relative;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-badge.seat-top {
|
|
||||||
top: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-badge.seat-right {
|
|
||||||
right: -20px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%) rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-badge.seat-bottom {
|
|
||||||
bottom: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-badge.seat-left {
|
|
||||||
left: -20px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%) rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-badge.is-turn {
|
|
||||||
border-color: rgba(244, 222, 163, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-badge.offline {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-card {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid rgba(255, 248, 215, 0.32);
|
|
||||||
background:
|
|
||||||
linear-gradient(145deg, #b3e79c, #4eaf4a 46%, #2f7e28 100%);
|
|
||||||
color: #f7fff7;
|
|
||||||
font-weight: 800;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 2px 4px rgba(255, 255, 255, 0.18),
|
|
||||||
0 6px 14px rgba(0, 0, 0, 0.22);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-card img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-badge.seat-right .avatar-card img {
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-badge.seat-left .avatar-card img {
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-meta p {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #eef5ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-meta strong {
|
|
||||||
font-size: 15px;
|
|
||||||
color: #ffd85c;
|
|
||||||
text-shadow: 0 0 10px rgba(255, 216, 92, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dealer-mark,
|
|
||||||
.missing-mark {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dealer-mark {
|
|
||||||
position: absolute;
|
|
||||||
right: -8px;
|
|
||||||
bottom: -6px;
|
|
||||||
background: linear-gradient(180deg, #ffe38a 0%, #f1b92e 100%);
|
|
||||||
color: #5f3200;
|
|
||||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.missing-mark {
|
|
||||||
margin-left: auto;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
background: linear-gradient(180deg, rgba(114, 219, 149, 0.2) 0%, rgba(21, 148, 88, 0.34) 100%);
|
|
||||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.missing-mark img {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.missing-mark span {
|
|
||||||
color: #effff5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wall {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
filter: drop-shadow(0 6px 8px rgba(0, 0, 0, 0.22));
|
|
||||||
}
|
|
||||||
|
|
||||||
.wall img {
|
|
||||||
display: block;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wall-top,
|
|
||||||
.wall-bottom {
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wall-left,
|
|
||||||
.wall-right {
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wall-top {
|
|
||||||
top: 154px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wall-top img,
|
|
||||||
.wall-bottom img {
|
|
||||||
width: 24px;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wall-right {
|
|
||||||
right: 132px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wall-left {
|
|
||||||
left: 132px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wall-left img,
|
|
||||||
.wall-right img {
|
|
||||||
width: 36px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wall-bottom {
|
|
||||||
bottom: 176px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.center-deck {
|
.center-deck {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -393,6 +393,10 @@
|
|||||||
border-color: rgba(244, 222, 163, 0.72);
|
border-color: rgba(244, 222, 163, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.picture-scene .player-badge.offline {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
.picture-scene .avatar-card {
|
.picture-scene .avatar-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -425,6 +429,22 @@
|
|||||||
color: #eef5ff;
|
color: #eef5ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.picture-scene .player-badge.seat-right .player-meta,
|
||||||
|
.picture-scene .player-badge.seat-left .player-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 48px;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picture-scene .player-badge.seat-right .player-meta p,
|
||||||
|
.picture-scene .player-badge.seat-left .player-meta p {
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.picture-scene .dealer-mark,
|
.picture-scene .dealer-mark,
|
||||||
.picture-scene .missing-mark {
|
.picture-scene .missing-mark {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -461,6 +481,10 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.picture-scene .missing-mark span {
|
||||||
|
color: #effff5;
|
||||||
|
}
|
||||||
|
|
||||||
.wall {
|
.wall {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -542,6 +566,15 @@
|
|||||||
left: 110px;
|
left: 110px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.center-wind-square {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.center-desk {
|
.center-desk {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|||||||
151
src/components/game/WindSquare.vue
Normal file
151
src/components/game/WindSquare.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import triangleIcon from '../../assets/images/icons/triangle.svg'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
seatWinds: {
|
||||||
|
top: string
|
||||||
|
right: string
|
||||||
|
bottom: string
|
||||||
|
left: string
|
||||||
|
}
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="wind-square">
|
||||||
|
<img class="triangle top" :src="triangleIcon" alt="" />
|
||||||
|
<img class="triangle right" :src="triangleIcon" alt="" />
|
||||||
|
<img class="triangle bottom" :src="triangleIcon" alt="" />
|
||||||
|
<img class="triangle left" :src="triangleIcon" alt="" />
|
||||||
|
|
||||||
|
<span class="wind-slot wind-top">
|
||||||
|
<img class="wind-icon" :src="seatWinds.top" alt="北位风" />
|
||||||
|
</span>
|
||||||
|
<span class="wind-slot wind-right">
|
||||||
|
<img class="wind-icon" :src="seatWinds.right" alt="右位风" />
|
||||||
|
</span>
|
||||||
|
<span class="wind-slot wind-bottom">
|
||||||
|
<img class="wind-icon" :src="seatWinds.bottom" alt="本位风" />
|
||||||
|
</span>
|
||||||
|
<span class="wind-slot wind-left">
|
||||||
|
<img class="wind-icon" :src="seatWinds.left" alt="左位风" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wind-square {
|
||||||
|
position: relative;
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-square::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 45%, rgba(244, 222, 151, 0.2), rgba(12, 40, 30, 0.05) 65%),
|
||||||
|
linear-gradient(145deg, rgba(21, 82, 58, 0.42), rgba(8, 38, 27, 0.16));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 225, 165, 0.15),
|
||||||
|
0 6px 12px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.triangle {
|
||||||
|
position: absolute;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0.96;
|
||||||
|
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.triangle.top {
|
||||||
|
top: 4px;
|
||||||
|
left: 32px;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
filter:
|
||||||
|
hue-rotate(-8deg)
|
||||||
|
saturate(1.35)
|
||||||
|
brightness(1.1)
|
||||||
|
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.triangle.right {
|
||||||
|
top: 32px;
|
||||||
|
right: 4px;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
filter:
|
||||||
|
hue-rotate(16deg)
|
||||||
|
saturate(1.28)
|
||||||
|
brightness(1.08)
|
||||||
|
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.triangle.bottom {
|
||||||
|
bottom: 4px;
|
||||||
|
left: 32px;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
filter:
|
||||||
|
hue-rotate(34deg)
|
||||||
|
saturate(1.2)
|
||||||
|
brightness(1.02)
|
||||||
|
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.triangle.left {
|
||||||
|
top: 32px;
|
||||||
|
left: 4px;
|
||||||
|
transform: rotate(270deg);
|
||||||
|
filter:
|
||||||
|
hue-rotate(-26deg)
|
||||||
|
saturate(1.24)
|
||||||
|
brightness(1.06)
|
||||||
|
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-slot {
|
||||||
|
position: absolute;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 9px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 237, 186, 0.92), rgba(232, 191, 105, 0.84));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.56),
|
||||||
|
0 3px 8px rgba(0, 0, 0, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-top {
|
||||||
|
top: 12px;
|
||||||
|
left: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-right {
|
||||||
|
top: 48px;
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-bottom {
|
||||||
|
bottom: 12px;
|
||||||
|
left: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-left {
|
||||||
|
top: 48px;
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -16,11 +16,17 @@ import TopPlayerCard from '../components/game/TopPlayerCard.vue'
|
|||||||
import RightPlayerCard from '../components/game/RightPlayerCard.vue'
|
import RightPlayerCard from '../components/game/RightPlayerCard.vue'
|
||||||
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
|
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
|
||||||
import LeftPlayerCard from '../components/game/LeftPlayerCard.vue'
|
import LeftPlayerCard from '../components/game/LeftPlayerCard.vue'
|
||||||
|
import WindSquare from '../components/game/WindSquare.vue'
|
||||||
|
import eastWind from '../assets/images/direction/dong.png'
|
||||||
|
import southWind from '../assets/images/direction/nan.png'
|
||||||
|
import westWind from '../assets/images/direction/xi.png'
|
||||||
|
import northWind from '../assets/images/direction/bei.png'
|
||||||
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
|
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
|
||||||
import type {SeatKey} from '../game/seat'
|
import type {SeatKey} from '../game/seat'
|
||||||
import type {GameAction} from '../game/actions'
|
import type {GameAction} from '../game/actions'
|
||||||
import {dispatchGameAction} from '../game/dispatcher'
|
import {dispatchGameAction} from '../game/dispatcher'
|
||||||
import {readStoredAuth} from '../utils/auth-storage'
|
import {refreshAccessToken} from '../api/auth'
|
||||||
|
import {clearAuth, readStoredAuth, writeStoredAuth} from '../utils/auth-storage'
|
||||||
import type {WsStatus} from '../ws/client'
|
import type {WsStatus} from '../ws/client'
|
||||||
import {wsClient} from '../ws/client'
|
import {wsClient} from '../ws/client'
|
||||||
import {sendWsMessage} from '../ws/sender'
|
import {sendWsMessage} from '../ws/sender'
|
||||||
@@ -64,6 +70,8 @@ const isTrustMode = ref(false)
|
|||||||
const menuTriggerActive = ref(false)
|
const menuTriggerActive = ref(false)
|
||||||
let menuTriggerTimer: number | null = null
|
let menuTriggerTimer: number | null = null
|
||||||
let menuOpenTimer: number | null = null
|
let menuOpenTimer: number | null = null
|
||||||
|
let refreshingWsToken = false
|
||||||
|
let lastForcedRefreshAt = 0
|
||||||
|
|
||||||
const loggedInUserId = computed(() => {
|
const loggedInUserId = computed(() => {
|
||||||
const rawId = auth.value?.user?.id
|
const rawId = auth.value?.user?.id
|
||||||
@@ -173,6 +181,28 @@ const seatViews = computed<SeatViewModel[]>(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const seatWinds = computed<Record<SeatKey, string>>(() => {
|
||||||
|
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
|
||||||
|
const players = gamePlayers.value
|
||||||
|
const selfSeatIndex = myPlayer.value?.seatIndex ?? players.find((player) => player.playerId === loggedInUserId.value)?.seatIndex ?? 0
|
||||||
|
|
||||||
|
const directionBySeatIndex = [eastWind, southWind, westWind, northWind]
|
||||||
|
const result: Record<SeatKey, string> = {
|
||||||
|
top: northWind,
|
||||||
|
right: eastWind,
|
||||||
|
bottom: southWind,
|
||||||
|
left: westWind,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let absoluteSeat = 0; absoluteSeat < 4; absoluteSeat += 1) {
|
||||||
|
const relativeIndex = (absoluteSeat - selfSeatIndex + 4) % 4
|
||||||
|
const seatKey = tableOrder[relativeIndex] ?? 'top'
|
||||||
|
result[seatKey] = directionBySeatIndex[absoluteSeat] ?? northWind
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
const rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
|
const rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
|
||||||
|
|
||||||
const currentPhaseText = computed(() => {
|
const currentPhaseText = computed(() => {
|
||||||
@@ -255,13 +285,13 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
|
|
||||||
const displayName = seat.player.displayName || `玩家${seat.player.seatIndex + 1}`
|
const displayName = seat.player.displayName || `玩家${seat.player.seatIndex + 1}`
|
||||||
const avatarUrl = seat.isSelf
|
const avatarUrl = seat.isSelf
|
||||||
? (localCachedAvatarUrl.value || seat.player.avatarURL || '')
|
? (localCachedAvatarUrl.value || seat.player.avatarURL || '')
|
||||||
: (seat.player.avatarURL || '')
|
: (seat.player.avatarURL || '')
|
||||||
const selfDisplayName = seat.player.displayName || loggedInUserName.value || '你自己'
|
const selfDisplayName = seat.player.displayName || loggedInUserName.value || '你自己'
|
||||||
|
|
||||||
result[seat.key] = {
|
result[seat.key] = {
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
name: seat.isSelf ? selfDisplayName : displayName,
|
name: Array.from(seat.isSelf ? selfDisplayName : displayName).slice(0, 4).join(''),
|
||||||
dealer: seat.player.seatIndex === dealerIndex,
|
dealer: seat.player.seatIndex === dealerIndex,
|
||||||
isTurn: seat.isTurn,
|
isTurn: seat.isTurn,
|
||||||
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
|
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
|
||||||
@@ -409,26 +439,106 @@ function toGameAction(message: unknown): GameAction | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureWsConnected(): void {
|
function logoutToLogin(): void {
|
||||||
const token = auth.value?.token
|
clearAuth()
|
||||||
|
auth.value = null
|
||||||
|
wsClient.close()
|
||||||
|
void router.replace('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJwtExpMs(token: string): number | null {
|
||||||
|
const parts = token.split('.')
|
||||||
|
const payloadPart = parts[1]
|
||||||
|
if (!payloadPart) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalized = payloadPart.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||||
|
const payload = JSON.parse(window.atob(padded)) as { exp?: number }
|
||||||
|
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRefreshWsToken(token: string): boolean {
|
||||||
|
const expMs = decodeJwtExpMs(token)
|
||||||
|
if (!expMs) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return expMs <= Date.now() + 30_000
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWsToken(forceRefresh = false, logoutOnRefreshFail = false): Promise<string | null> {
|
||||||
|
const current = auth.value
|
||||||
|
if (!current?.token) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceRefresh && !shouldRefreshWsToken(current.token)) {
|
||||||
|
return current.token
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current.refreshToken || refreshingWsToken) {
|
||||||
|
return current.token
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshingWsToken = true
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshAccessToken({
|
||||||
|
token: current.token,
|
||||||
|
tokenType: current.tokenType,
|
||||||
|
refreshToken: current.refreshToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextAuth = {
|
||||||
|
...current,
|
||||||
|
token: refreshed.token,
|
||||||
|
tokenType: refreshed.tokenType ?? current.tokenType,
|
||||||
|
refreshToken: refreshed.refreshToken ?? current.refreshToken,
|
||||||
|
expiresIn: refreshed.expiresIn,
|
||||||
|
}
|
||||||
|
auth.value = nextAuth
|
||||||
|
writeStoredAuth(nextAuth)
|
||||||
|
return nextAuth.token
|
||||||
|
} catch {
|
||||||
|
if (logoutOnRefreshFail) {
|
||||||
|
logoutToLogin()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
refreshingWsToken = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWsConnected(forceRefresh = false): Promise<void> {
|
||||||
|
const token = await resolveWsToken(forceRefresh, false)
|
||||||
if (!token) {
|
if (!token) {
|
||||||
wsError.value = '未找到登录凭证,无法建立连接'
|
wsError.value = '未找到登录凭证,无法建立连接'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wsError.value = ''
|
wsError.value = ''
|
||||||
wsClient.connect(buildWsUrl(token), token)
|
wsClient.connect(buildWsUrl(), token)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconnectWsInternal(forceRefresh = false): Promise<boolean> {
|
||||||
|
const token = await resolveWsToken(forceRefresh, false)
|
||||||
|
if (!token) {
|
||||||
|
wsError.value = '未找到登录凭证,无法建立连接'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
wsError.value = ''
|
||||||
|
wsClient.reconnect(buildWsUrl(), token)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function reconnectWs(): void {
|
function reconnectWs(): void {
|
||||||
const token = auth.value?.token
|
void reconnectWsInternal()
|
||||||
if (!token) {
|
|
||||||
wsError.value = '未找到登录凭证,无法建立连接'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
wsError.value = ''
|
|
||||||
wsClient.reconnect(buildWsUrl(token), token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function backHall(): void {
|
function backHall(): void {
|
||||||
@@ -537,10 +647,25 @@ onMounted(() => {
|
|||||||
wsClient.onError((message: string) => {
|
wsClient.onError((message: string) => {
|
||||||
wsError.value = message
|
wsError.value = message
|
||||||
wsMessages.value.push(`[error] ${message}`)
|
wsMessages.value.push(`[error] ${message}`)
|
||||||
|
|
||||||
|
// WebSocket 握手失败时浏览器拿不到 401 状态码,统一按需强制刷新 token 后重连一次
|
||||||
|
const nowMs = Date.now()
|
||||||
|
if (nowMs - lastForcedRefreshAt > 5000) {
|
||||||
|
lastForcedRefreshAt = nowMs
|
||||||
|
void resolveWsToken(true, true).then((refreshedToken) => {
|
||||||
|
if (!refreshedToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wsError.value = ''
|
||||||
|
wsClient.reconnect(buildWsUrl(), refreshedToken)
|
||||||
|
}).catch(() => {
|
||||||
|
logoutToLogin()
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
unsubscribe = wsClient.onStatusChange(handler)
|
unsubscribe = wsClient.onStatusChange(handler)
|
||||||
ensureWsConnected()
|
void ensureWsConnected()
|
||||||
|
|
||||||
clockTimer = window.setInterval(() => {
|
clockTimer = window.setInterval(() => {
|
||||||
now.value = Date.now()
|
now.value = Date.now()
|
||||||
@@ -669,6 +794,8 @@ onBeforeUnmount(() => {
|
|||||||
<span>{{ seatDecor.right.missingSuitLabel }}</span>
|
<span>{{ seatDecor.right.missingSuitLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<WindSquare class="center-wind-square" :seat-winds="seatWinds"/>
|
||||||
|
|
||||||
|
|
||||||
<div class="bottom-control-panel">
|
<div class="bottom-control-panel">
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ function connectGameWs(): void {
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
wsClient.connect(buildWsUrl(token), token)
|
wsClient.connect(buildWsUrl(), token)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRooms(): Promise<void> {
|
async function refreshRooms(): Promise<void> {
|
||||||
|
|||||||
@@ -38,10 +38,15 @@ class WsClient {
|
|||||||
private buildUrl(): string {
|
private buildUrl(): string {
|
||||||
if (!this.token) return this.url
|
if (!this.token) return this.url
|
||||||
|
|
||||||
const hasQuery = this.url.includes('?')
|
try {
|
||||||
const connector = hasQuery ? '&' : '?'
|
const parsed = new URL(this.url)
|
||||||
|
parsed.searchParams.set('token', this.token)
|
||||||
return `${this.url}${connector}token=${encodeURIComponent(this.token)}`
|
return parsed.toString()
|
||||||
|
} catch {
|
||||||
|
const hasQuery = this.url.includes('?')
|
||||||
|
const connector = hasQuery ? '&' : '?'
|
||||||
|
return `${this.url}${connector}token=${encodeURIComponent(this.token)}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws'
|
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws'
|
||||||
|
|
||||||
export function buildWsUrl(token: string): string {
|
export function buildWsUrl(): string {
|
||||||
const baseUrl = /^wss?:\/\//.test(WS_BASE_URL)
|
const baseUrl = /^wss?:\/\//.test(WS_BASE_URL)
|
||||||
? new URL(WS_BASE_URL)
|
? new URL(WS_BASE_URL)
|
||||||
: new URL(
|
: new URL(
|
||||||
@@ -8,6 +8,5 @@ export function buildWsUrl(token: string): string {
|
|||||||
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`,
|
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
baseUrl.searchParams.set('token', token)
|
|
||||||
return baseUrl.toString()
|
return baseUrl.toString()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user