fix(admin): 改密后管理面板永久「连接中断」+ 顶部版本号多一个 v
- /ws 身份在握手时确定,而 startRealtime() 全生命周期只建一次连接;改密会作废 服务端全部会话,服务端以 1008 关闭该连接,前端对 1008 是永久停止重连 (给真鉴权失败设计的)→ 重新登录后无人重启连接,界面一直显示「连接中断」, 只有重开窗口才会用新会话重新握手。 修:新增 restartRealtime(),登录成功与退出登录时各重启一次(同时修掉 「访客浏览后登录,旧连接仍是 guest 身份」的问题);1008 语义保持不变。 - AdminLayout 顶部版本号模板写死 v 前缀,而 serverHealth 已是完整版本串 (dev_0.1.0)→ 显示成 vdev_0.1.0。 新增 web/src/admin/tests/event-realtime.test.ts 锁该行为(临时置空 restartRealtime 会失败);为让 node --test 能加载真实 store,测试解析钩子补 @/ 别名。
This commit is contained in:
1 parent
27bddefaad
commit
a5941be9c4
5 files changed
+125
-3
No files matched your search
@@ -100,6 +100,8 @@ async function refresh(): Promise<void> {
|
|||||||
|
|
||||||
async function logout(): Promise<void> {
|
async function logout(): Promise<void> {
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
|
// 会话已失效,旧连接仍是原身份,重连回落到访客态
|
||||||
|
data.restartRealtime()
|
||||||
toast.info('已退出登录,当前为只读访客视图')
|
toast.info('已退出登录,当前为只读访客视图')
|
||||||
void router.push({ name: 'dashboard' })
|
void router.push({ name: 'dashboard' })
|
||||||
}
|
}
|
||||||
@@ -165,7 +167,7 @@ onMounted(async () => {
|
|||||||
<span class="mx-badge" :class="`mx-badge-${connection.tone}`" :title="connection.hint">
|
<span class="mx-badge" :class="`mx-badge-${connection.tone}`" :title="connection.hint">
|
||||||
{{ connection.text }}
|
{{ connection.text }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="data.serverHealth" class="version mono">v{{ data.serverHealth }}</span>
|
<span v-if="data.serverHealth" class="version mono">{{ data.serverHealth }}</span>
|
||||||
<button
|
<button
|
||||||
class="mx-btn mx-btn-ghost mx-btn-icon"
|
class="mx-btn mx-btn-ghost mx-btn-icon"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ async function submit(): Promise<void> {
|
|||||||
}
|
}
|
||||||
toast.success(`欢迎回来,${auth.displayName}`)
|
toast.success(`欢迎回来,${auth.displayName}`)
|
||||||
await data.load()
|
await data.load()
|
||||||
|
// 登录后会话身份变了(此前可能是访客连接),重建 WS 让服务端重新识别身份
|
||||||
|
data.restartRealtime()
|
||||||
// 首次登录强制改密,改完才放行到业务页面
|
// 首次登录强制改密,改完才放行到业务页面
|
||||||
void router.push({ name: auth.user?.mustChangePassword ? 'password' : 'entry' })
|
void router.push({ name: auth.user?.mustChangePassword ? 'password' : 'entry' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,18 @@ export const useEventStore = defineStore('admin-event', () => {
|
|||||||
realtime.connect()
|
realtime.connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重建实时连接。
|
||||||
|
*
|
||||||
|
* WS 身份在握手时确定,登录 / 退出 / 改密后会话已变,必须关掉旧连接重连,
|
||||||
|
* 否则会一直带着旧身份(被服务端以 1008 拒绝后则停在 offline,永远显示「连接中断」)。
|
||||||
|
*/
|
||||||
|
function restartRealtime(): void {
|
||||||
|
realtime?.close()
|
||||||
|
realtime = null
|
||||||
|
if (!mockMode.value && loaded.value) startRealtime()
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshStandings(): Promise<void> {
|
async function refreshStandings(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const [next, athletes] = await Promise.all([
|
const [next, athletes] = await Promise.all([
|
||||||
@@ -161,6 +173,7 @@ export const useEventStore = defineStore('admin-event', () => {
|
|||||||
canWrite,
|
canWrite,
|
||||||
load,
|
load,
|
||||||
checkHealth,
|
checkHealth,
|
||||||
|
restartRealtime,
|
||||||
refreshStandings,
|
refreshStandings,
|
||||||
refreshEvents,
|
refreshEvents,
|
||||||
eventById,
|
eventById,
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { adminApi, mockMode } from '../api/client'
|
||||||
|
import { useEventStore } from '../stores/event'
|
||||||
|
|
||||||
|
// P0-1:WS 身份在握手时确定,登录 / 退出 / 改密后必须重建连接。
|
||||||
|
// 旧连接被服务端 1008 拒绝后客户端会停止重连,没有 restartRealtime 就永远停在 offline。
|
||||||
|
|
||||||
|
class FakeSocket {
|
||||||
|
static readonly CONNECTING = 0
|
||||||
|
static readonly OPEN = 1
|
||||||
|
static readonly CLOSED = 3
|
||||||
|
static instances: FakeSocket[] = []
|
||||||
|
|
||||||
|
readyState = FakeSocket.CONNECTING
|
||||||
|
onopen: (() => void) | null = null
|
||||||
|
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||||
|
onerror: (() => void) | null = null
|
||||||
|
onclose: ((event: { code: number }) => void) | null = null
|
||||||
|
readonly url: string
|
||||||
|
readonly sent: string[] = []
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url
|
||||||
|
FakeSocket.instances.push(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: string): void {
|
||||||
|
this.sent.push(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.readyState = FakeSocket.CLOSED
|
||||||
|
this.onclose?.({ code: 1006 })
|
||||||
|
}
|
||||||
|
|
||||||
|
open(): void {
|
||||||
|
this.readyState = FakeSocket.OPEN
|
||||||
|
this.onopen?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('restartRealtime 关闭旧连接并按当前会话重建', async () => {
|
||||||
|
FakeSocket.instances = []
|
||||||
|
;(globalThis as { WebSocket?: unknown }).WebSocket = FakeSocket
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
|
||||||
|
adminApi.bootstrap = async () => ({
|
||||||
|
settings: null,
|
||||||
|
teams: [],
|
||||||
|
sports: [],
|
||||||
|
events: [],
|
||||||
|
profiles: [],
|
||||||
|
standings: [],
|
||||||
|
athleteStandings: [],
|
||||||
|
})
|
||||||
|
adminApi.health = async () => {
|
||||||
|
throw new Error('health 不可达')
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useEventStore()
|
||||||
|
await store.load()
|
||||||
|
assert.equal(store.realtimeStatus, 'connecting')
|
||||||
|
assert.equal(FakeSocket.instances.length, 1)
|
||||||
|
|
||||||
|
const first = FakeSocket.instances[0]
|
||||||
|
assert.ok(first)
|
||||||
|
first.open()
|
||||||
|
assert.equal(store.realtimeStatus, 'online')
|
||||||
|
|
||||||
|
// 改密后服务端作废全部会话,旧连接以 1008 关闭并停在 offline
|
||||||
|
first.readyState = FakeSocket.CLOSED
|
||||||
|
first.onclose?.({ code: 1008 })
|
||||||
|
assert.equal(store.realtimeStatus, 'offline')
|
||||||
|
|
||||||
|
store.restartRealtime()
|
||||||
|
assert.equal(first.readyState, FakeSocket.CLOSED)
|
||||||
|
assert.equal(FakeSocket.instances.length, 2)
|
||||||
|
assert.equal(store.realtimeStatus, 'connecting')
|
||||||
|
const second = FakeSocket.instances[1]
|
||||||
|
assert.ok(second)
|
||||||
|
second.open()
|
||||||
|
assert.equal(store.realtimeStatus, 'online')
|
||||||
|
|
||||||
|
// 示例数据模式不建真实连接
|
||||||
|
mockMode.value = true
|
||||||
|
store.restartRealtime()
|
||||||
|
assert.equal(store.realtimeStatus, 'idle')
|
||||||
|
assert.equal(FakeSocket.instances.length, 2)
|
||||||
|
})
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* `node --test` 专用的解析钩子:把 TS 源码里的无扩展名相对导入补成 .ts。
|
* `node --test` 专用的解析钩子:把 TS 源码里的无扩展名相对导入补成 .ts,
|
||||||
* 只服务于测试,不参与 Vite 构建。
|
* 并把 Vite 的 `@/` 别名映射到 src/。只服务于测试,不参与 Vite 构建。
|
||||||
*/
|
*/
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const SRC = new URL('../../', import.meta.url)
|
||||||
|
|
||||||
|
function resolveAlias(specifier) {
|
||||||
|
if (!specifier.startsWith('@/')) return null
|
||||||
|
const base = new URL(specifier.slice(2), SRC)
|
||||||
|
for (const ext of ['.ts', '.mjs', '.js', '/index.ts']) {
|
||||||
|
const candidate = new URL(base.href + ext)
|
||||||
|
if (existsSync(fileURLToPath(candidate))) return candidate.href
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolve(specifier, context, next) {
|
export async function resolve(specifier, context, next) {
|
||||||
|
const alias = resolveAlias(specifier)
|
||||||
|
if (alias !== null) return next(alias, context)
|
||||||
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier)) {
|
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier)) {
|
||||||
for (const ext of ['.ts', '.mjs', '.js']) {
|
for (const ext of ['.ts', '.mjs', '.js']) {
|
||||||
const candidate = new URL(specifier + ext, context.parentURL)
|
const candidate = new URL(specifier + ext, context.parentURL)
|
||||||
|
|||||||
Reference in new issue
Block a user