mirror of
https://github.com/setube/ogame-vue-ts.git
synced 2026-05-12 07:55:11 +08:00
feat: 新增队列与外交通知组件及新手引导
引入队列通知(QueueNotifications)和外交通知(DiplomaticNotifications)组件,优化主界面队列与外交报告展示,支持一键查看与跳转。重构App.vue,移除原有队列展示,改为弹出式通知,支持功能解锁提示与新手引导(TutorialOverlay)。完善NPC外交事件处理,导弹攻击等行为影响好感度并生成报告。优化部分UI细节与多语言文本,提升交互体验。
This commit is contained in:
438
src/components/TutorialOverlay.vue
Normal file
438
src/components/TutorialOverlay.vue
Normal file
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="tutorialState.isActive && currentStep" class="tutorial-overlay">
|
||||
<!-- Dark overlay parts (4 rectangles around the highlight) -->
|
||||
<template v-if="highlightRect && currentStep.target">
|
||||
<!-- Top overlay -->
|
||||
<div
|
||||
class="tutorial-backdrop-part"
|
||||
:style="{
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '100%',
|
||||
height: `${highlightRect.top}px`
|
||||
}"
|
||||
/>
|
||||
<!-- Bottom overlay -->
|
||||
<div
|
||||
class="tutorial-backdrop-part"
|
||||
:style="{
|
||||
top: `${highlightRect.bottom}px`,
|
||||
left: '0',
|
||||
width: '100%',
|
||||
height: `calc(100% - ${highlightRect.bottom}px)`
|
||||
}"
|
||||
/>
|
||||
<!-- Left overlay -->
|
||||
<div
|
||||
class="tutorial-backdrop-part"
|
||||
:style="{
|
||||
top: `${highlightRect.top}px`,
|
||||
left: '0',
|
||||
width: `${highlightRect.left}px`,
|
||||
height: `${highlightRect.height}px`
|
||||
}"
|
||||
/>
|
||||
<!-- Right overlay -->
|
||||
<div
|
||||
class="tutorial-backdrop-part"
|
||||
:style="{
|
||||
top: `${highlightRect.top}px`,
|
||||
left: `${highlightRect.right}px`,
|
||||
width: `calc(100% - ${highlightRect.right}px)`,
|
||||
height: `${highlightRect.height}px`
|
||||
}"
|
||||
/>
|
||||
<!-- Highlight border -->
|
||||
<div
|
||||
class="tutorial-highlight-border"
|
||||
:style="{
|
||||
top: `${highlightRect.top}px`,
|
||||
left: `${highlightRect.left}px`,
|
||||
width: `${highlightRect.width}px`,
|
||||
height: `${highlightRect.height}px`
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Full overlay for center placement (no target) -->
|
||||
<div v-else class="tutorial-backdrop-full" />
|
||||
|
||||
<!-- Tutorial tooltip -->
|
||||
<div
|
||||
v-if="tooltipPosition"
|
||||
class="tutorial-tooltip"
|
||||
:class="`tutorial-tooltip-${currentStep.placement || 'center'}`"
|
||||
:style="{
|
||||
top: tooltipPosition.top,
|
||||
left: tooltipPosition.left,
|
||||
transform: tooltipPosition.transform
|
||||
}"
|
||||
>
|
||||
<Card class="tutorial-card">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-lg">{{ t(currentStep.title) }}</CardTitle>
|
||||
<Button v-if="currentStep.canSkip" variant="ghost" size="icon" class="h-6 w-6" @click="skipTutorial">
|
||||
<XIcon :size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription class="text-sm mt-2">
|
||||
{{ t(currentStep.content) }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="pt-0 space-y-3">
|
||||
<!-- Progress bar -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{{ t('tutorial.progress') }}</span>
|
||||
<span>{{ tutorialState.currentStepIndex + 1 }} / {{ totalSteps }}</span>
|
||||
</div>
|
||||
<div class="w-full bg-secondary rounded-full h-1.5">
|
||||
<div class="bg-primary h-1.5 rounded-full transition-all duration-300" :style="{ width: `${progress}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation buttons -->
|
||||
<div class="flex gap-2">
|
||||
<Button v-if="tutorialState.currentStepIndex > 0" variant="outline" size="sm" @click="previousStep">
|
||||
<ChevronLeftIcon :size="16" class="mr-1" />
|
||||
{{ t('tutorial.previous') }}
|
||||
</Button>
|
||||
|
||||
<Button v-if="!isLastStep" class="ml-auto" size="sm" @click="handleNext" :disabled="!canProceed">
|
||||
{{ t('tutorial.next') }}
|
||||
<ChevronRightIcon :size="16" class="ml-1" />
|
||||
</Button>
|
||||
|
||||
<Button v-else class="ml-auto" size="sm" @click="completeTutorial">
|
||||
{{ t('tutorial.completeButton') }}
|
||||
<CheckIcon :size="16" class="ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useTutorial, getTutorialSteps } from '@/composables/useTutorial'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { XIcon, ChevronLeftIcon, ChevronRightIcon, CheckIcon } from 'lucide-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { tutorialState, currentStep, progress, isLastStep, nextStep, previousStep, skipTutorial, completeTutorial } = useTutorial()
|
||||
|
||||
const highlightRect = ref<DOMRect | null>(null)
|
||||
const tooltipPosition = ref<{ top: string; left: string; transform: string } | null>(null)
|
||||
const totalSteps = computed(() => getTutorialSteps().length)
|
||||
const isMobile = ref(false)
|
||||
|
||||
// Check if current step can proceed
|
||||
const canProceed = computed(() => {
|
||||
if (!currentStep.value) return false
|
||||
|
||||
// 所有步骤都允许手动点击下一步
|
||||
return true
|
||||
})
|
||||
|
||||
// 检测是否为移动端
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
// Calculate highlight and tooltip positions
|
||||
const updatePositions = () => {
|
||||
if (!currentStep.value) {
|
||||
highlightRect.value = null
|
||||
tooltipPosition.value = null
|
||||
return
|
||||
}
|
||||
|
||||
// 检测移动端
|
||||
checkMobile()
|
||||
|
||||
// For center placement, no target element needed
|
||||
if (!currentStep.value.target || currentStep.value.placement === 'center') {
|
||||
highlightRect.value = null
|
||||
tooltipPosition.value = {
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Find target element
|
||||
const targetElement = document.querySelector(currentStep.value.target)
|
||||
if (!targetElement) {
|
||||
// Fallback to center if target not found
|
||||
highlightRect.value = null
|
||||
tooltipPosition.value = {
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-scroll target element into view
|
||||
targetElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center'
|
||||
})
|
||||
|
||||
// Get target element rect
|
||||
const rect = targetElement.getBoundingClientRect()
|
||||
const padding = currentStep.value.highlightPadding || 8
|
||||
|
||||
// Set highlight rect with padding
|
||||
highlightRect.value = new DOMRect(rect.left - padding, rect.top - padding, rect.width + padding * 2, rect.height + padding * 2)
|
||||
|
||||
// 获取视口尺寸
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
// 气泡的预估尺寸(根据视口大小响应式调整)
|
||||
const tooltipWidth = isMobile.value ? Math.min(viewportWidth - 32, 360) : 480
|
||||
const tooltipHeight = isMobile.value ? 280 : 300 // 预估高度
|
||||
|
||||
// 计算各个方向的可用空间
|
||||
const spaceTop = rect.top
|
||||
const spaceBottom = viewportHeight - rect.bottom
|
||||
const spaceLeft = rect.left
|
||||
const spaceRight = viewportWidth - rect.right
|
||||
|
||||
const tooltipOffset = isMobile.value ? 8 : 16 // 移动端使用更小的间距
|
||||
const edgeMargin = isMobile.value ? 8 : 16 // 距离边缘的最小距离
|
||||
|
||||
// 根据优先级和可用空间自动选择最佳位置
|
||||
let placement = currentStep.value.placement || 'bottom'
|
||||
let finalPosition: { top: string; left: string; transform: string }
|
||||
|
||||
// 移动端优先使用 bottom 或 top 位置
|
||||
if (isMobile.value) {
|
||||
// 移动端强制使用 top/bottom,忽略 left/right
|
||||
if (placement === 'left' || placement === 'right') {
|
||||
placement = spaceBottom > spaceTop ? 'bottom' : 'top'
|
||||
}
|
||||
}
|
||||
|
||||
// 智能位置选择:如果指定位置空间不足,自动调整
|
||||
const canFitTop = spaceTop >= tooltipHeight + tooltipOffset + edgeMargin
|
||||
const canFitBottom = spaceBottom >= tooltipHeight + tooltipOffset + edgeMargin
|
||||
const canFitLeft = spaceLeft >= tooltipWidth + tooltipOffset + edgeMargin
|
||||
const canFitRight = spaceRight >= tooltipWidth + tooltipOffset + edgeMargin
|
||||
|
||||
// 自动调整位置
|
||||
if (placement === 'top' && !canFitTop && canFitBottom) {
|
||||
placement = 'bottom'
|
||||
} else if (placement === 'bottom' && !canFitBottom && canFitTop) {
|
||||
placement = 'top'
|
||||
} else if (placement === 'left' && !canFitLeft && canFitRight) {
|
||||
placement = 'right'
|
||||
} else if (placement === 'right' && !canFitRight && canFitLeft) {
|
||||
placement = 'left'
|
||||
}
|
||||
|
||||
// 计算位置
|
||||
switch (placement) {
|
||||
case 'top': {
|
||||
let left = rect.left + rect.width / 2
|
||||
// 确保不超出左右边界
|
||||
left = Math.max(tooltipWidth / 2 + edgeMargin, Math.min(left, viewportWidth - tooltipWidth / 2 - edgeMargin))
|
||||
finalPosition = {
|
||||
top: `${Math.max(edgeMargin, rect.top - tooltipOffset)}px`,
|
||||
left: `${left}px`,
|
||||
transform: 'translate(-50%, -100%)'
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'bottom': {
|
||||
let left = rect.left + rect.width / 2
|
||||
// 确保不超出左右边界
|
||||
left = Math.max(tooltipWidth / 2 + edgeMargin, Math.min(left, viewportWidth - tooltipWidth / 2 - edgeMargin))
|
||||
finalPosition = {
|
||||
top: `${Math.min(viewportHeight - tooltipHeight - edgeMargin, rect.bottom + tooltipOffset)}px`,
|
||||
left: `${left}px`,
|
||||
transform: 'translate(-50%, 0)'
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'left': {
|
||||
let top = rect.top + rect.height / 2
|
||||
// 确保不超出上下边界
|
||||
top = Math.max(tooltipHeight / 2 + edgeMargin, Math.min(top, viewportHeight - tooltipHeight / 2 - edgeMargin))
|
||||
finalPosition = {
|
||||
top: `${top}px`,
|
||||
left: `${Math.max(edgeMargin, rect.left - tooltipOffset)}px`,
|
||||
transform: 'translate(-100%, -50%)'
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'right': {
|
||||
let top = rect.top + rect.height / 2
|
||||
// 确保不超出上下边界
|
||||
top = Math.max(tooltipHeight / 2 + edgeMargin, Math.min(top, viewportHeight - tooltipHeight / 2 - edgeMargin))
|
||||
finalPosition = {
|
||||
top: `${top}px`,
|
||||
left: `${Math.min(viewportWidth - tooltipWidth - edgeMargin, rect.right + tooltipOffset)}px`,
|
||||
transform: 'translate(0, -50%)'
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
finalPosition = {
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}
|
||||
}
|
||||
|
||||
tooltipPosition.value = finalPosition
|
||||
}
|
||||
|
||||
// Handle next step
|
||||
const handleNext = () => {
|
||||
if (canProceed.value) {
|
||||
nextStep()
|
||||
}
|
||||
}
|
||||
|
||||
// Update positions when step changes
|
||||
watch(
|
||||
() => currentStep.value,
|
||||
() => {
|
||||
// Wait for DOM update and route change
|
||||
setTimeout(() => {
|
||||
updatePositions()
|
||||
}, 100)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Update positions on window resize or scroll
|
||||
const handleResize = () => {
|
||||
checkMobile()
|
||||
updatePositions()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile()
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('scroll', handleResize, true)
|
||||
updatePositions()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleResize, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tutorial-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tutorial-backdrop-part {
|
||||
position: fixed;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
pointer-events: auto;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tutorial-backdrop-full {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tutorial-highlight-border {
|
||||
position: fixed;
|
||||
background: transparent;
|
||||
border: 4px solid rgba(59, 130, 246, 0.5);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.tutorial-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10001;
|
||||
pointer-events: auto;
|
||||
max-width: 480px;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
/* 移动端样式调整 */
|
||||
@media (max-width: 767px) {
|
||||
.tutorial-tooltip {
|
||||
max-width: calc(100vw - 32px);
|
||||
min-width: calc(100vw - 32px);
|
||||
width: calc(100vw - 32px);
|
||||
}
|
||||
|
||||
.tutorial-tooltip-center {
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
|
||||
.tutorial-card {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tutorial-highlight-border {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.tutorial-tooltip-center {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.tutorial-tooltip-center {
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
}
|
||||
|
||||
.tutorial-card {
|
||||
animation: tutorial-fade-in 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes tutorial-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
.dark .tutorial-backdrop-part {
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.dark .tutorial-backdrop-full {
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.dark .tutorial-highlight-border {
|
||||
border-color: rgba(59, 130, 246, 0.6);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user