Files
ogame-vue-ts/src/components/TutorialOverlay.vue
谦君 cfcde0b024 feat: 新增队列与外交通知组件及新手引导
引入队列通知(QueueNotifications)和外交通知(DiplomaticNotifications)组件,优化主界面队列与外交报告展示,支持一键查看与跳转。重构App.vue,移除原有队列展示,改为弹出式通知,支持功能解锁提示与新手引导(TutorialOverlay)。完善NPC外交事件处理,导弹攻击等行为影响好感度并生成报告。优化部分UI细节与多语言文本,提升交互体验。
2025-12-17 21:06:34 +08:00

439 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>