新增复盘服务相关DTO、Controller和Service 实现复盘页面容器组件ReviewPageContainer 更新前端页面架构文档与开发计划 移除DemoGameController中的演示复盘接口 补充复盘服务单元测试
607 lines
19 KiB
Vue
607 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import { Client, type IFrame, type IMessage } from '@stomp/stompjs'
|
||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||
import AppShell from './components/AppShell.vue'
|
||
import GamePageContainer from './pages/GamePageContainer.vue'
|
||
import ReviewPageContainer from './pages/ReviewPageContainer.vue'
|
||
import RoomPageContainer from './pages/RoomPageContainer.vue'
|
||
import type {
|
||
ApiResponse,
|
||
DiagnosticItem,
|
||
GameStateResponse,
|
||
PrivateActionMessage,
|
||
PrivateTeachingMessage,
|
||
PublicGameMessage,
|
||
RoomSummaryResponse,
|
||
ScoreChangeCardView,
|
||
SettlementCardView,
|
||
ViewUserOption
|
||
} from './types/game'
|
||
import type { ReviewSummaryResponse } from './types/review'
|
||
import {
|
||
actionScopeLabelMap,
|
||
formatSettlementType,
|
||
readNumber,
|
||
readString,
|
||
phaseLabelMap,
|
||
toActionLabel,
|
||
toSettlementDetailView,
|
||
triggerEventTypeLabelMap
|
||
} from './utils/gameUi'
|
||
|
||
const busy = ref(false)
|
||
const error = ref('')
|
||
const info = ref('H5 对局原型已接入公共事件、私有动作与私有教学消息,当前开始补正式动作面板。')
|
||
|
||
const ownerId = ref('host-1')
|
||
const ownerName = ref('房主')
|
||
const joinUserId = ref('player-2')
|
||
const joinUserName = ref('玩家二')
|
||
const roomIdInput = ref('')
|
||
const lackSuit = ref('WAN')
|
||
|
||
const room = ref<RoomSummaryResponse | null>(null)
|
||
const game = ref<GameStateResponse | null>(null)
|
||
const reviewSummary = ref<ReviewSummaryResponse | null>(null)
|
||
const currentUserId = ref('host-1')
|
||
|
||
const wsStatus = ref<'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'>('idle')
|
||
const reviewLoading = ref(false)
|
||
const publicEvents = ref<PublicGameMessage[]>([])
|
||
const privateAction = ref<PrivateActionMessage | null>(null)
|
||
const privateTeaching = ref<PrivateTeachingMessage | null>(null)
|
||
let stompClient: Client | null = null
|
||
|
||
const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION')
|
||
const canDiscard = computed(
|
||
() =>
|
||
game.value?.phase === 'PLAYING' &&
|
||
!game.value.selfSeat.won &&
|
||
game.value.selfSeat.playerId === currentUserId.value &&
|
||
game.value.currentSeatNo === game.value.selfSeat.seatNo
|
||
)
|
||
|
||
const publicSeats = computed(() => game.value?.seats ?? [])
|
||
|
||
const privateActionSummary = computed(() => {
|
||
if (!privateAction.value) {
|
||
return ''
|
||
}
|
||
const scopeLabel = actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope
|
||
const readableActions = privateAction.value.availableActions.map(toActionLabel)
|
||
return `${scopeLabel}:${readableActions.join(' / ')}`
|
||
})
|
||
|
||
const turnActionCandidates = computed(() => {
|
||
if (privateAction.value?.actionScope !== 'TURN') {
|
||
return []
|
||
}
|
||
// 当前回合的出牌依然直接通过点击手牌触发,动作面板只承载额外动作,避免一个动作出现两套入口。
|
||
return privateAction.value.candidates.filter((candidate) => candidate.actionType !== 'DISCARD')
|
||
})
|
||
|
||
const responseActionCandidates = computed(() => {
|
||
if (privateAction.value?.actionScope !== 'RESPONSE') {
|
||
return []
|
||
}
|
||
return privateAction.value.candidates
|
||
})
|
||
|
||
const responseContextSummary = computed(() => {
|
||
if (!privateAction.value || privateAction.value.actionScope !== 'RESPONSE') {
|
||
return ''
|
||
}
|
||
const triggerLabel = privateAction.value.triggerEventType
|
||
? triggerEventTypeLabelMap[privateAction.value.triggerEventType] ?? privateAction.value.triggerEventType
|
||
: '响应窗口'
|
||
const sourceSeatLabel =
|
||
privateAction.value.sourceSeatNo !== null ? `来源座位 ${privateAction.value.sourceSeatNo}` : '来源座位未知'
|
||
const triggerTileLabel = privateAction.value.triggerTile ? `目标牌 ${privateAction.value.triggerTile}` : '未携带目标牌'
|
||
return `${triggerLabel},${sourceSeatLabel},${triggerTileLabel}`
|
||
})
|
||
|
||
const actionPanelHint = computed(() => {
|
||
if (!privateAction.value) {
|
||
return '等待下一条私有动作消息。收到回合动作或响应动作后,这里会自动切换到对应面板。'
|
||
}
|
||
if (privateAction.value.actionScope === 'TURN') {
|
||
return '出牌通过上方手牌直接执行;若当前可自摸胡或可杠,则在这里统一提交。'
|
||
}
|
||
return `${responseContextSummary.value}。请在当前响应窗口关闭前完成选择。`
|
||
})
|
||
|
||
const recommendedDiscardTile = computed(() => {
|
||
// 当前教学服务只对出牌建议生效,因此 recommendedAction 在现阶段可直接视为“建议打出的牌名”。
|
||
if (!privateTeaching.value?.recommendedAction) {
|
||
return null
|
||
}
|
||
if (!game.value?.selfSeat.handTiles.includes(privateTeaching.value.recommendedAction)) {
|
||
return null
|
||
}
|
||
return privateTeaching.value.recommendedAction
|
||
})
|
||
|
||
const teachingHint = computed(() => {
|
||
if (!privateTeaching.value) {
|
||
return '尚未收到当前视角的教学建议。'
|
||
}
|
||
if (recommendedDiscardTile.value) {
|
||
return `教学建议优先打出 ${recommendedDiscardTile.value}。`
|
||
}
|
||
return privateTeaching.value.explanation
|
||
})
|
||
|
||
const teachingCandidates = computed(() => privateTeaching.value?.candidates ?? [])
|
||
|
||
const currentViewLabel = computed(() => {
|
||
if (!game.value) {
|
||
return currentUserId.value
|
||
}
|
||
return `${game.value.selfSeat.nickname} · 座位 ${game.value.selfSeat.seatNo}`
|
||
})
|
||
|
||
const viewUserOptions = computed<ViewUserOption[]>(() => {
|
||
if (game.value) {
|
||
const options = new Map<string, ViewUserOption>()
|
||
options.set(game.value.selfSeat.playerId, {
|
||
userId: game.value.selfSeat.playerId,
|
||
label: `${game.value.selfSeat.nickname} · 座位 ${game.value.selfSeat.seatNo}`,
|
||
seatNo: game.value.selfSeat.seatNo
|
||
})
|
||
for (const seat of game.value.seats) {
|
||
options.set(seat.playerId, {
|
||
userId: seat.playerId,
|
||
label: `${seat.nickname} · 座位 ${seat.seatNo}`,
|
||
seatNo: seat.seatNo
|
||
})
|
||
}
|
||
return [...options.values()].sort((left, right) => left.seatNo - right.seatNo)
|
||
}
|
||
|
||
return [
|
||
{ userId: ownerId.value, label: `${ownerName.value} · 房主`, seatNo: 0 },
|
||
{ userId: joinUserId.value, label: `${joinUserName.value} · 玩家二`, seatNo: 1 }
|
||
]
|
||
})
|
||
|
||
const actionDiagnosticItems = computed<DiagnosticItem[]>(() => {
|
||
if (!privateAction.value) {
|
||
return []
|
||
}
|
||
return [
|
||
{
|
||
key: 'scope',
|
||
label: '作用域',
|
||
value: actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope
|
||
},
|
||
{
|
||
key: 'window',
|
||
label: '窗口 ID',
|
||
value: privateAction.value.windowId ? privateAction.value.windowId.slice(0, 8) : '当前回合'
|
||
},
|
||
{
|
||
key: 'event',
|
||
label: '触发来源',
|
||
value: privateAction.value.triggerEventType
|
||
? triggerEventTypeLabelMap[privateAction.value.triggerEventType] ?? privateAction.value.triggerEventType
|
||
: '当前回合'
|
||
},
|
||
{
|
||
key: 'source',
|
||
label: '来源座位',
|
||
value: privateAction.value.sourceSeatNo !== null ? `座位 ${privateAction.value.sourceSeatNo}` : '无'
|
||
}
|
||
]
|
||
})
|
||
|
||
const latestSettlementCard = computed<SettlementCardView | null>(() => {
|
||
const settlementEvent = publicEvents.value.find((event) => event.eventType === 'SETTLEMENT_APPLIED')
|
||
if (!settlementEvent) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
settlementType: formatSettlementType(readString(settlementEvent.payload.settlementType)),
|
||
actionType: toActionLabel(readString(settlementEvent.payload.actionType) ?? '-'),
|
||
actorSeatNo: readNumber(settlementEvent.payload.actorSeatNo),
|
||
sourceSeatNo: readNumber(settlementEvent.payload.sourceSeatNo),
|
||
triggerTile: readString(settlementEvent.payload.triggerTile),
|
||
detail: toSettlementDetailView(settlementEvent.payload.settlementDetail)
|
||
}
|
||
})
|
||
|
||
const latestSettlementScoreChanges = computed<ScoreChangeCardView[]>(() => {
|
||
const settlementEvent = publicEvents.value.find((event) => event.eventType === 'SETTLEMENT_APPLIED')
|
||
if (!settlementEvent) {
|
||
return []
|
||
}
|
||
|
||
const latestSettlementType = readString(settlementEvent.payload.settlementType)
|
||
return publicEvents.value
|
||
.filter((event) => event.eventType === 'SCORE_CHANGED')
|
||
.filter((event) => readString(event.payload.settlementType) === latestSettlementType)
|
||
.slice(0, 4)
|
||
.map((event) => ({
|
||
seatNo: event.seatNo ?? -1,
|
||
delta: readNumber(event.payload.delta) ?? 0,
|
||
score: readNumber(event.payload.score),
|
||
settlementType: readString(event.payload.settlementType)
|
||
}))
|
||
})
|
||
|
||
async function requestJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||
const response = await fetch(url, {
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
...options
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`请求失败:${url}`)
|
||
}
|
||
|
||
const payload = (await response.json()) as ApiResponse<T>
|
||
if (!payload.success) {
|
||
throw new Error(payload.message || '接口处理失败')
|
||
}
|
||
|
||
return payload.data
|
||
}
|
||
|
||
async function runTask(task: () => Promise<void>) {
|
||
busy.value = true
|
||
error.value = ''
|
||
try {
|
||
await task()
|
||
} catch (err) {
|
||
error.value = err instanceof Error ? err.message : '操作失败'
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
async function loadCurrentReview() {
|
||
if (!game.value) {
|
||
error.value = '请先进入一局真实对局,再加载当前视角复盘'
|
||
return
|
||
}
|
||
reviewLoading.value = true
|
||
error.value = ''
|
||
try {
|
||
reviewSummary.value = await requestJson<ReviewSummaryResponse>(
|
||
`/api/games/${game.value.gameId}/review?userId=${encodeURIComponent(currentUserId.value)}`
|
||
)
|
||
info.value = '已加载当前视角的真实局后复盘摘要。'
|
||
} catch (err) {
|
||
error.value = err instanceof Error ? err.message : '复盘加载失败'
|
||
} finally {
|
||
reviewLoading.value = false
|
||
}
|
||
}
|
||
|
||
function buildBrokerUrl() {
|
||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||
const host = window.location.hostname
|
||
const port = window.location.port === '5173' ? '8080' : window.location.port
|
||
return `${protocol}://${host}${port ? `:${port}` : ''}/ws`
|
||
}
|
||
|
||
function disconnectWs() {
|
||
if (stompClient) {
|
||
stompClient.deactivate()
|
||
stompClient = null
|
||
}
|
||
wsStatus.value = 'disconnected'
|
||
}
|
||
|
||
function connectWs(gameId: string, userId: string) {
|
||
disconnectWs()
|
||
wsStatus.value = 'connecting'
|
||
publicEvents.value = []
|
||
privateAction.value = null
|
||
privateTeaching.value = null
|
||
|
||
const client = new Client({
|
||
brokerURL: buildBrokerUrl(),
|
||
reconnectDelay: 3000,
|
||
onConnect() {
|
||
wsStatus.value = 'connected'
|
||
client.subscribe(`/topic/games/${gameId}/events`, (message: IMessage) => {
|
||
const payload = JSON.parse(message.body) as PublicGameMessage
|
||
handlePublicEvent(payload)
|
||
})
|
||
client.subscribe(`/topic/users/${userId}/actions`, (message: IMessage) => {
|
||
// 私有动作消息是前端动作面板的唯一事实来源,后续所有按钮启用状态都以这里为准。
|
||
privateAction.value = JSON.parse(message.body) as PrivateActionMessage
|
||
})
|
||
client.subscribe(`/topic/users/${userId}/teaching`, (message: IMessage) => {
|
||
privateTeaching.value = JSON.parse(message.body) as PrivateTeachingMessage
|
||
})
|
||
},
|
||
onStompError(frame: IFrame) {
|
||
wsStatus.value = 'error'
|
||
error.value = frame.headers.message ?? 'WebSocket 订阅失败'
|
||
},
|
||
onWebSocketClose() {
|
||
wsStatus.value = 'disconnected'
|
||
},
|
||
onWebSocketError() {
|
||
wsStatus.value = 'error'
|
||
}
|
||
})
|
||
|
||
client.activate()
|
||
stompClient = client
|
||
}
|
||
|
||
watch(
|
||
() => [game.value?.gameId, currentUserId.value] as const,
|
||
([gameId, userId]) => {
|
||
reviewSummary.value = null
|
||
// 视角一旦切换,必须重新订阅对应用户的私有主题,避免沿用上一个玩家的动作/教学消息。
|
||
if (gameId) {
|
||
connectWs(gameId, userId)
|
||
} else {
|
||
disconnectWs()
|
||
}
|
||
}
|
||
)
|
||
|
||
onBeforeUnmount(() => {
|
||
disconnectWs()
|
||
})
|
||
|
||
async function createRoom() {
|
||
await runTask(async () => {
|
||
const result = await requestJson<RoomSummaryResponse>('/api/rooms', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
ownerUserId: ownerId.value,
|
||
allowBotFill: true
|
||
})
|
||
})
|
||
room.value = result
|
||
roomIdInput.value = result.roomId
|
||
currentUserId.value = ownerId.value
|
||
info.value = `房间已创建,邀请码 ${result.inviteCode}。`
|
||
})
|
||
}
|
||
|
||
async function loadRoom() {
|
||
if (!roomIdInput.value) {
|
||
error.value = '请先输入房间 ID'
|
||
return
|
||
}
|
||
await runTask(async () => {
|
||
room.value = await requestJson<RoomSummaryResponse>(`/api/rooms/${roomIdInput.value}`)
|
||
info.value = '已刷新房间状态。'
|
||
})
|
||
}
|
||
|
||
async function joinRoom() {
|
||
if (!roomIdInput.value) {
|
||
error.value = '请先输入房间 ID'
|
||
return
|
||
}
|
||
await runTask(async () => {
|
||
room.value = await requestJson<RoomSummaryResponse>(`/api/rooms/${roomIdInput.value}/join`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
userId: joinUserId.value,
|
||
displayName: joinUserName.value
|
||
})
|
||
})
|
||
info.value = `${joinUserName.value} 已加入房间。`
|
||
})
|
||
}
|
||
|
||
async function toggleReady(userId: string, ready: boolean) {
|
||
if (!room.value) {
|
||
return
|
||
}
|
||
const targetRoomId = room.value.roomId
|
||
await runTask(async () => {
|
||
room.value = await requestJson<RoomSummaryResponse>(`/api/rooms/${targetRoomId}/ready`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
userId,
|
||
ready
|
||
})
|
||
})
|
||
info.value = ready ? `${userId} 已准备。` : `${userId} 已取消准备。`
|
||
})
|
||
}
|
||
|
||
async function startGame() {
|
||
if (!room.value) {
|
||
error.value = '请先创建或加载房间'
|
||
return
|
||
}
|
||
const targetRoomId = room.value.roomId
|
||
await runTask(async () => {
|
||
currentUserId.value = ownerId.value
|
||
game.value = await requestJson<GameStateResponse>(`/api/rooms/${targetRoomId}/start`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
operatorUserId: ownerId.value
|
||
})
|
||
})
|
||
info.value = '对局已开始,进入定缺阶段。'
|
||
})
|
||
}
|
||
|
||
async function refreshGameState() {
|
||
if (!game.value) {
|
||
return
|
||
}
|
||
const targetGameId = game.value.gameId
|
||
await runTask(async () => {
|
||
game.value = await requestJson<GameStateResponse>(
|
||
`/api/games/${targetGameId}/state?userId=${encodeURIComponent(currentUserId.value)}`
|
||
)
|
||
info.value = '已刷新对局状态。'
|
||
})
|
||
}
|
||
|
||
async function switchUserView(userId: string) {
|
||
if (currentUserId.value === userId) {
|
||
return
|
||
}
|
||
|
||
currentUserId.value = userId
|
||
|
||
if (!game.value) {
|
||
info.value = `已切换到 ${userId} 视角。`
|
||
return
|
||
}
|
||
|
||
const targetGameId = game.value.gameId
|
||
await runTask(async () => {
|
||
game.value = await requestJson<GameStateResponse>(
|
||
`/api/games/${targetGameId}/state?userId=${encodeURIComponent(userId)}`
|
||
)
|
||
info.value = `已切换到 ${userId} 视角,并同步最新对局状态。`
|
||
})
|
||
}
|
||
|
||
async function submitAction(actionType: string, tile?: string, sourceSeatNo?: number | null) {
|
||
if (!game.value) {
|
||
return
|
||
}
|
||
const targetGameId = game.value.gameId
|
||
await runTask(async () => {
|
||
game.value = await requestJson<GameStateResponse>(`/api/games/${targetGameId}/actions`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
userId: currentUserId.value,
|
||
actionType,
|
||
tile: tile ?? null,
|
||
sourceSeatNo: sourceSeatNo ?? null
|
||
})
|
||
})
|
||
|
||
const readableAction = toActionLabel(actionType)
|
||
if (actionType === 'DISCARD' && tile) {
|
||
info.value = `已打出 ${tile}。`
|
||
return
|
||
}
|
||
if (tile) {
|
||
info.value = `已提交 ${readableAction} ${tile}。`
|
||
return
|
||
}
|
||
info.value = `已提交动作 ${readableAction}。`
|
||
})
|
||
}
|
||
|
||
function selectLack() {
|
||
return submitAction('SELECT_LACK_SUIT', lackSuit.value)
|
||
}
|
||
|
||
function discard(tile: string) {
|
||
return submitAction('DISCARD', tile)
|
||
}
|
||
|
||
function submitCandidateAction(actionType: string, tile: string | null) {
|
||
const sourceSeatNo = privateAction.value?.sourceSeatNo ?? null
|
||
return submitAction(actionType, tile ?? undefined, sourceSeatNo)
|
||
}
|
||
|
||
function handlePublicEvent(event: PublicGameMessage) {
|
||
// 公共事件除了进入时间线,也负责驱动前端把已经失效的私有动作面板收起来。
|
||
if (shouldClearPrivateActionByEvent(event)) {
|
||
privateAction.value = null
|
||
}
|
||
publicEvents.value = [event, ...publicEvents.value].slice(0, 16)
|
||
}
|
||
|
||
function shouldClearPrivateActionByEvent(event: PublicGameMessage) {
|
||
if (!privateAction.value) {
|
||
return false
|
||
}
|
||
|
||
const currentAction = privateAction.value
|
||
const selfSeatNo = game.value?.selfSeat.seatNo ?? null
|
||
|
||
if (event.eventType === 'RESPONSE_WINDOW_CLOSED') {
|
||
const sourceSeatNo = readNumber(event.payload.sourceSeatNo)
|
||
return currentAction.actionScope === 'RESPONSE' && sourceSeatNo === currentAction.sourceSeatNo
|
||
}
|
||
|
||
if (event.eventType === 'GAME_PHASE_CHANGED') {
|
||
return readString(event.payload.phase) !== 'PLAYING'
|
||
}
|
||
|
||
if (event.eventType === 'TILE_DISCARDED') {
|
||
return currentAction.actionScope === 'TURN' && selfSeatNo !== null && event.seatNo === selfSeatNo
|
||
}
|
||
|
||
if (event.eventType === 'TURN_SWITCHED') {
|
||
const nextSeatNo = readNumber(event.payload.currentSeatNo)
|
||
return currentAction.actionScope === 'TURN' && selfSeatNo !== null && nextSeatNo !== selfSeatNo
|
||
}
|
||
|
||
return false
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<AppShell :info="info" :error="error">
|
||
<RoomPageContainer
|
||
:busy="busy"
|
||
:owner-id="ownerId"
|
||
:owner-name="ownerName"
|
||
:join-user-id="joinUserId"
|
||
:join-user-name="joinUserName"
|
||
:room-id-input="roomIdInput"
|
||
:room="room"
|
||
:current-user-id="currentUserId"
|
||
@update:owner-id="ownerId = $event"
|
||
@update:owner-name="ownerName = $event"
|
||
@update:join-user-id="joinUserId = $event"
|
||
@update:join-user-name="joinUserName = $event"
|
||
@update:room-id-input="roomIdInput = $event"
|
||
@create-room="createRoom"
|
||
@load-room="loadRoom"
|
||
@switch-user-view="switchUserView"
|
||
@join-room="joinRoom"
|
||
@toggle-ready="toggleReady"
|
||
@start-game="startGame"
|
||
/>
|
||
|
||
<GamePageContainer
|
||
:game="game"
|
||
:current-user-id="currentUserId"
|
||
:current-view-label="currentViewLabel"
|
||
:view-user-options="viewUserOptions"
|
||
:lack-suit="lackSuit"
|
||
:can-select-lack="canSelectLack"
|
||
:can-discard="canDiscard"
|
||
:recommended-discard-tile="recommendedDiscardTile"
|
||
:private-teaching="privateTeaching"
|
||
:private-teaching-hint="teachingHint"
|
||
:private-teaching-candidates="teachingCandidates"
|
||
:private-action="privateAction"
|
||
:private-action-summary="privateActionSummary"
|
||
:action-panel-hint="actionPanelHint"
|
||
:turn-action-candidates="turnActionCandidates"
|
||
:response-action-candidates="responseActionCandidates"
|
||
:response-context-summary="responseContextSummary"
|
||
:action-diagnostic-items="actionDiagnosticItems"
|
||
:public-seats="publicSeats"
|
||
:public-events="publicEvents"
|
||
:latest-settlement-card="latestSettlementCard"
|
||
:latest-settlement-score-changes="latestSettlementScoreChanges"
|
||
:ws-status="wsStatus"
|
||
@update:lack-suit="lackSuit = $event"
|
||
@refresh-game-state="refreshGameState"
|
||
@select-lack="selectLack"
|
||
@switch-user-view="switchUserView"
|
||
@discard="discard"
|
||
@submit-candidate-action="submitCandidateAction"
|
||
/>
|
||
|
||
<ReviewPageContainer :review="reviewSummary" :loading="reviewLoading" @load-review="loadCurrentReview" />
|
||
</AppShell>
|
||
</template>
|