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:
300
src/components/DiplomaticNotifications.vue
Normal file
300
src/components/DiplomaticNotifications.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<Popover v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="relative">
|
||||
<ScrollText class="h-4 w-4" />
|
||||
<Badge
|
||||
v-if="unreadCount > 0"
|
||||
variant="destructive"
|
||||
class="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center text-xs"
|
||||
>
|
||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-96 p-0" align="end">
|
||||
<div class="flex items-center justify-between p-4 border-b">
|
||||
<h3 class="font-semibold">{{ t('diplomacy.notifications') }}</h3>
|
||||
<Button v-if="unreadCount > 0" variant="ghost" size="sm" @click="markAllAsRead">
|
||||
{{ t('diplomacy.markAllRead') }}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea class="h-96">
|
||||
<div v-if="reports.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
{{ t('diplomacy.noReports') }}
|
||||
</div>
|
||||
<div v-else class="divide-y">
|
||||
<div
|
||||
v-for="report in reports"
|
||||
:key="report.id"
|
||||
class="p-4 hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
:class="{ 'bg-primary/5': !report.read }"
|
||||
@click="handleReportClick(report)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<component :is="getEventIcon(report.eventType)" class="h-4 w-4" :class="getEventIconColor(report.eventType)" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium text-sm">{{ report.npcName }}</span>
|
||||
<Badge :variant="getStatusBadgeVariant(report.newStatus)" class="text-xs">
|
||||
{{ getStatusText(report.newStatus) }}
|
||||
</Badge>
|
||||
<span v-if="!report.read" class="ml-auto">
|
||||
<Badge variant="destructive" class="h-2 w-2 p-0 rounded-full" />
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground line-clamp-2">
|
||||
{{ report.messageKey && report.messageParams ? t(report.messageKey, report.messageParams) : report.message }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{{ formatRelativeTime((Date.now() - report.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div v-if="reports.length > 0" class="p-2 border-t">
|
||||
<Button variant="ghost" size="sm" class="w-full" @click="goToDiplomacy">
|
||||
{{ t('diplomacy.viewAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- 外交报告详情对话框 -->
|
||||
<Dialog :open="detailDialogOpen" @update:open="detailDialogOpen = $event">
|
||||
<DialogContent class="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<component
|
||||
v-if="selectedReport"
|
||||
:is="getEventIcon(selectedReport.eventType)"
|
||||
class="h-5 w-5"
|
||||
:class="getEventIconColor(selectedReport.eventType)"
|
||||
/>
|
||||
{{ t('diplomacy.reportDetails') }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="selectedReport" class="space-y-4">
|
||||
<!-- NPC信息 -->
|
||||
<div class="flex items-center gap-3 p-4 bg-muted/50 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="font-semibold text-lg">{{ selectedReport.npcName }}</h3>
|
||||
<Badge :variant="getStatusBadgeVariant(selectedReport.newStatus)">
|
||||
{{ getStatusText(selectedReport.newStatus) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ formatRelativeTime((Date.now() - selectedReport.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 事件描述 -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-semibold text-sm">{{ t('diplomacy.eventDescription') }}</h4>
|
||||
<p class="text-sm p-3 bg-muted/30 rounded-md">
|
||||
{{
|
||||
selectedReport.messageKey && selectedReport.messageParams
|
||||
? t(selectedReport.messageKey, selectedReport.messageParams)
|
||||
: selectedReport.message
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 关系变化 -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- 好感度变化 -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-semibold text-sm">{{ t('diplomacy.reputationChange') }}</h4>
|
||||
<div class="p-3 bg-muted/30 rounded-md">
|
||||
<div class="flex items-center justify-between text-sm mb-2">
|
||||
<span class="text-muted-foreground">{{ t('diplomacy.before') }}</span>
|
||||
<span class="font-semibold" :class="getReputationColor(selectedReport.newReputation - selectedReport.reputationChange)">
|
||||
{{ selectedReport.newReputation - selectedReport.reputationChange > 0 ? '+' : ''
|
||||
}}{{ selectedReport.newReputation - selectedReport.reputationChange }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-center text-lg font-bold my-1"
|
||||
:class="selectedReport.reputationChange >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'"
|
||||
>
|
||||
{{ selectedReport.reputationChange >= 0 ? '+' : '' }}{{ selectedReport.reputationChange }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm mt-2">
|
||||
<span class="text-muted-foreground">{{ t('diplomacy.after') }}</span>
|
||||
<span class="font-semibold" :class="getReputationColor(selectedReport.newReputation)">
|
||||
{{ selectedReport.newReputation > 0 ? '+' : '' }}{{ selectedReport.newReputation }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关系状态变化 -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-semibold text-sm">{{ t('diplomacy.statusChange') }}</h4>
|
||||
<div class="p-3 bg-muted/30 rounded-md">
|
||||
<div class="flex items-center justify-between text-sm mb-2">
|
||||
<span class="text-muted-foreground">{{ t('diplomacy.before') }}</span>
|
||||
<Badge :variant="getStatusBadgeVariant(selectedReport.oldStatus)" class="text-xs">
|
||||
{{ getStatusText(selectedReport.oldStatus) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-center my-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm mt-2">
|
||||
<span class="text-muted-foreground">{{ t('diplomacy.after') }}</span>
|
||||
<Badge :variant="getStatusBadgeVariant(selectedReport.newStatus)" class="text-xs">
|
||||
{{ getStatusText(selectedReport.newStatus) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="detailDialogOpen = false">{{ t('common.close') }}</Button>
|
||||
<Button @click="goToDiplomacyFromDialog">{{ t('diplomacy.viewDiplomacy') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { ScrollText, Gift, Sword, Eye, Trash2, Skull } from 'lucide-vue-next'
|
||||
import { RelationStatus, DiplomaticEventType } from '@/types/game'
|
||||
import type { DiplomaticReport } from '@/types/game'
|
||||
import { formatRelativeTime } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
const gameStore = useGameStore()
|
||||
const { t } = useI18n()
|
||||
const isOpen = ref(false)
|
||||
const detailDialogOpen = ref(false)
|
||||
const selectedReport = ref<DiplomaticReport | null>(null)
|
||||
|
||||
const reports = computed(() => {
|
||||
return (gameStore.player.diplomaticReports || []).slice().reverse().slice(0, 20) // 最近20条
|
||||
})
|
||||
|
||||
const unreadCount = computed(() => {
|
||||
return (gameStore.player.diplomaticReports || []).filter(r => !r.read).length
|
||||
})
|
||||
|
||||
const getEventIcon = (eventType: DiplomaticReport['eventType']) => {
|
||||
switch (eventType) {
|
||||
case DiplomaticEventType.GiftResources:
|
||||
return Gift
|
||||
case DiplomaticEventType.Attack:
|
||||
case DiplomaticEventType.AllyAttacked:
|
||||
return Sword
|
||||
case DiplomaticEventType.Spy:
|
||||
return Eye
|
||||
case DiplomaticEventType.StealDebris:
|
||||
return Trash2
|
||||
case DiplomaticEventType.DestroyPlanet:
|
||||
return Skull
|
||||
default:
|
||||
return ScrollText
|
||||
}
|
||||
}
|
||||
|
||||
const getEventIconColor = (eventType: DiplomaticReport['eventType']) => {
|
||||
switch (eventType) {
|
||||
case DiplomaticEventType.GiftResources:
|
||||
return 'text-green-500'
|
||||
case DiplomaticEventType.Attack:
|
||||
case DiplomaticEventType.DestroyPlanet:
|
||||
return 'text-red-500'
|
||||
case DiplomaticEventType.AllyAttacked:
|
||||
return 'text-orange-500'
|
||||
case DiplomaticEventType.Spy:
|
||||
return 'text-purple-500'
|
||||
case DiplomaticEventType.StealDebris:
|
||||
return 'text-yellow-500'
|
||||
default:
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadgeVariant = (status: RelationStatus) => {
|
||||
switch (status) {
|
||||
case RelationStatus.Hostile:
|
||||
return 'destructive'
|
||||
case RelationStatus.Friendly:
|
||||
return 'default'
|
||||
case RelationStatus.Neutral:
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: RelationStatus) => {
|
||||
switch (status) {
|
||||
case RelationStatus.Hostile:
|
||||
return t('diplomacy.status.hostile')
|
||||
case RelationStatus.Friendly:
|
||||
return t('diplomacy.status.friendly')
|
||||
case RelationStatus.Neutral:
|
||||
default:
|
||||
return t('diplomacy.status.neutral')
|
||||
}
|
||||
}
|
||||
|
||||
const getReputationColor = (reputation: number | null) => {
|
||||
if (reputation === null) return 'text-muted-foreground'
|
||||
if (reputation >= 20) return 'text-green-600 dark:text-green-400'
|
||||
if (reputation <= -20) return 'text-red-600 dark:text-red-400'
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
|
||||
const handleReportClick = (report: DiplomaticReport) => {
|
||||
// 标记为已读
|
||||
report.read = true
|
||||
// 设置选中的报告并打开详情对话框
|
||||
selectedReport.value = report
|
||||
detailDialogOpen.value = true
|
||||
// 关闭通知面板
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const markAllAsRead = () => {
|
||||
gameStore.player.diplomaticReports?.forEach(report => {
|
||||
report.read = true
|
||||
})
|
||||
}
|
||||
|
||||
const goToDiplomacy = () => {
|
||||
isOpen.value = false
|
||||
router.push('/diplomacy')
|
||||
}
|
||||
|
||||
const goToDiplomacyFromDialog = () => {
|
||||
detailDialogOpen.value = false
|
||||
router.push('/diplomacy')
|
||||
}
|
||||
</script>
|
||||
@@ -175,15 +175,21 @@
|
||||
<CardContent class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('resources.metal') }}:</span>
|
||||
<span class="font-medium"><NumberWithTooltip :value="totalStats.metal" /></span>
|
||||
<span class="font-medium">
|
||||
<NumberWithTooltip :value="totalStats.metal" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('resources.crystal') }}:</span>
|
||||
<span class="font-medium"><NumberWithTooltip :value="totalStats.crystal" /></span>
|
||||
<span class="font-medium">
|
||||
<NumberWithTooltip :value="totalStats.crystal" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('resources.deuterium') }}:</span>
|
||||
<span class="font-medium"><NumberWithTooltip :value="totalStats.deuterium" /></span>
|
||||
<span class="font-medium">
|
||||
<NumberWithTooltip :value="totalStats.deuterium" />
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -305,11 +311,15 @@
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span class="text-muted-foreground">{{ t(`resources.${resourceType.key}`) }}:</span>
|
||||
<span class="font-medium"><NumberWithTooltip :value="unitCost[resourceType.key]" /></span>
|
||||
<span class="font-medium">
|
||||
<NumberWithTooltip :value="unitCost[resourceType.key]" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm pt-2 border-t">
|
||||
<span class="text-muted-foreground">{{ t('player.points') }}:</span>
|
||||
<span class="font-bold text-primary"><NumberWithTooltip :value="pointsPerUnit" /></span>
|
||||
<span class="font-bold text-primary">
|
||||
<NumberWithTooltip :value="pointsPerUnit" />
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -341,15 +351,21 @@
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span>{{ t('resources.metal') }}:</span>
|
||||
<span class="font-medium"><NumberWithTooltip :value="batchCost.metal" /></span>
|
||||
<span class="font-medium">
|
||||
<NumberWithTooltip :value="batchCost.metal" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ t('resources.crystal') }}:</span>
|
||||
<span class="font-medium"><NumberWithTooltip :value="batchCost.crystal" /></span>
|
||||
<span class="font-medium">
|
||||
<NumberWithTooltip :value="batchCost.crystal" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ t('resources.deuterium') }}:</span>
|
||||
<span class="font-medium"><NumberWithTooltip :value="batchCost.deuterium" /></span>
|
||||
<span class="font-medium">
|
||||
<NumberWithTooltip :value="batchCost.deuterium" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -470,7 +486,7 @@
|
||||
const showFleetStorageColumn = computed(() => {
|
||||
if (props.type === 'building') {
|
||||
const buildingType = props.itemType as BuildingType
|
||||
return buildingType === 'shipyard'
|
||||
return buildingType === 'shipyard' || buildingType === 'hangar'
|
||||
} else if (props.type === 'technology') {
|
||||
const techType = props.itemType as TechnologyType
|
||||
return techType === 'computerTechnology'
|
||||
@@ -662,43 +678,84 @@
|
||||
const storageBonus = 1 + (activeBonuses.value.storageCapacityBonus || 0) / 100
|
||||
const baseCapacity = 10000
|
||||
|
||||
if (buildingType === 'metalMine') {
|
||||
production = Math.floor(1500 * level * Math.pow(1.5, level) * resourceBonus)
|
||||
consumption = Math.floor(10 * level * Math.pow(1.1, level))
|
||||
} else if (buildingType === 'crystalMine') {
|
||||
production = Math.floor(1000 * level * Math.pow(1.5, level) * resourceBonus)
|
||||
consumption = Math.floor(10 * level * Math.pow(1.1, level))
|
||||
} else if (buildingType === 'deuteriumSynthesizer') {
|
||||
production = Math.floor(500 * level * Math.pow(1.5, level) * resourceBonus)
|
||||
consumption = Math.floor(10 * level * Math.pow(1.1, level))
|
||||
} else if (buildingType === 'solarPlant') {
|
||||
production = Math.floor(50 * level * Math.pow(1.1, level) * energyBonus)
|
||||
} else if (buildingType === 'metalStorage') {
|
||||
capacity = Math.floor(baseCapacity * Math.pow(2, level) * storageBonus)
|
||||
} else if (buildingType === 'crystalStorage') {
|
||||
capacity = Math.floor(baseCapacity * Math.pow(2, level) * storageBonus)
|
||||
} else if (buildingType === 'deuteriumTank') {
|
||||
capacity = Math.floor(baseCapacity * Math.pow(2, level) * storageBonus)
|
||||
} else if (buildingType === 'darkMatterCollector') {
|
||||
capacity = 1000 + level * 100
|
||||
production = Math.floor(25 * level * Math.pow(1.5, level))
|
||||
} else if (buildingType === 'darkMatterTank') {
|
||||
const darkMatterBaseCapacity = 1000
|
||||
capacity = Math.floor(darkMatterBaseCapacity * Math.pow(2, level) * storageBonus)
|
||||
} else if (buildingType === 'fusionReactor') {
|
||||
production = Math.floor(150 * level * Math.pow(1.15, level))
|
||||
} else if (buildingType === 'shipyard') {
|
||||
fleetStorage = 1000 * level
|
||||
} else if (buildingType === 'terraformer') {
|
||||
spaceBonus = 30
|
||||
} else if (buildingType === 'lunarBase') {
|
||||
spaceBonus = 30
|
||||
} else if (buildingType === 'roboticsFactory') {
|
||||
buildSpeedBonus = level
|
||||
} else if (buildingType === 'naniteFactory') {
|
||||
buildSpeedBonus = level * 2
|
||||
} else if (buildingType === 'researchLab') {
|
||||
researchSpeedBonus = level
|
||||
// Building calculation configuration
|
||||
const buildingCalculations: Record<string, (level: number) => Partial<{
|
||||
production: number
|
||||
consumption: number
|
||||
capacity: number
|
||||
fleetStorage: number
|
||||
spaceBonus: number
|
||||
buildSpeedBonus: number
|
||||
researchSpeedBonus: number
|
||||
}>> = {
|
||||
metalMine: (lvl) => ({
|
||||
production: Math.floor(1500 * lvl * Math.pow(1.5, lvl) * resourceBonus),
|
||||
consumption: Math.floor(10 * lvl * Math.pow(1.1, lvl))
|
||||
}),
|
||||
crystalMine: (lvl) => ({
|
||||
production: Math.floor(1000 * lvl * Math.pow(1.5, lvl) * resourceBonus),
|
||||
consumption: Math.floor(10 * lvl * Math.pow(1.1, lvl))
|
||||
}),
|
||||
deuteriumSynthesizer: (lvl) => ({
|
||||
production: Math.floor(500 * lvl * Math.pow(1.5, lvl) * resourceBonus),
|
||||
consumption: Math.floor(10 * lvl * Math.pow(1.1, lvl))
|
||||
}),
|
||||
solarPlant: (lvl) => ({
|
||||
production: Math.floor(50 * lvl * Math.pow(1.1, lvl) * energyBonus)
|
||||
}),
|
||||
metalStorage: (lvl) => ({
|
||||
capacity: Math.floor(baseCapacity * Math.pow(2, lvl) * storageBonus)
|
||||
}),
|
||||
crystalStorage: (lvl) => ({
|
||||
capacity: Math.floor(baseCapacity * Math.pow(2, lvl) * storageBonus)
|
||||
}),
|
||||
deuteriumTank: (lvl) => ({
|
||||
capacity: Math.floor(baseCapacity * Math.pow(2, lvl) * storageBonus)
|
||||
}),
|
||||
darkMatterCollector: (lvl) => ({
|
||||
capacity: 1000 + lvl * 100,
|
||||
production: Math.floor(25 * lvl * Math.pow(1.5, lvl))
|
||||
}),
|
||||
darkMatterTank: (lvl) => ({
|
||||
capacity: Math.floor(1000 * Math.pow(2, lvl) * storageBonus)
|
||||
}),
|
||||
fusionReactor: (lvl) => ({
|
||||
production: Math.floor(150 * lvl * Math.pow(1.15, lvl))
|
||||
}),
|
||||
shipyard: (lvl) => ({
|
||||
fleetStorage: 1000 * lvl
|
||||
}),
|
||||
hangar: (lvl) => ({
|
||||
fleetStorage: 500 * lvl
|
||||
}),
|
||||
terraformer: () => ({
|
||||
spaceBonus: 30
|
||||
}),
|
||||
lunarBase: () => ({
|
||||
spaceBonus: 30
|
||||
}),
|
||||
roboticsFactory: (lvl) => ({
|
||||
buildSpeedBonus: lvl
|
||||
}),
|
||||
naniteFactory: (lvl) => ({
|
||||
buildSpeedBonus: lvl * 2
|
||||
}),
|
||||
researchLab: (lvl) => ({
|
||||
researchSpeedBonus: lvl
|
||||
})
|
||||
}
|
||||
|
||||
// Apply calculations if configuration exists
|
||||
const calc = buildingCalculations[buildingType]
|
||||
if (calc) {
|
||||
const result = calc(level)
|
||||
production = result.production ?? production
|
||||
consumption = result.consumption ?? consumption
|
||||
capacity = result.capacity ?? capacity
|
||||
fleetStorage = result.fleetStorage ?? fleetStorage
|
||||
spaceBonus = result.spaceBonus ?? spaceBonus
|
||||
buildSpeedBonus = result.buildSpeedBonus ?? buildSpeedBonus
|
||||
researchSpeedBonus = result.researchSpeedBonus ?? researchSpeedBonus
|
||||
}
|
||||
|
||||
const points = pointsLogic.calculateBuildingPoints(buildingType, level - 1, level)
|
||||
|
||||
@@ -53,7 +53,13 @@
|
||||
<div v-if="npc.allies && npc.allies.length > 0" class="pt-2 border-t">
|
||||
<p class="text-sm text-muted-foreground mb-2">{{ t('diplomacy.alliedWith') }}:</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Badge v-for="allyId in npc.allies.slice(0, 3)" :key="allyId" variant="outline" class="text-xs">
|
||||
<Badge
|
||||
v-for="allyId in npc.allies.slice(0, 3)"
|
||||
:key="allyId"
|
||||
variant="outline"
|
||||
class="text-xs cursor-pointer hover:bg-accent transition-colors"
|
||||
@click="scrollToAlly(allyId)"
|
||||
>
|
||||
{{ getAllyName(allyId) }}
|
||||
</Badge>
|
||||
<Badge v-if="npc.allies.length > 3" variant="outline" class="text-xs">
|
||||
@@ -80,7 +86,9 @@
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<component :is="getEventIcon(recentEvent.reason)" class="h-3 w-3" />
|
||||
<span>{{ getEventText(recentEvent.reason) }}</span>
|
||||
<span class="text-muted-foreground">{{ formatTime(Date.now() - recentEvent.timestamp) }} {{ t('diplomacy.ago') }}</span>
|
||||
<span class="text-muted-foreground">
|
||||
{{ formatRelativeTime((Date.now() - recentEvent.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -98,7 +106,7 @@
|
||||
import { Gift, Globe, Sword, Eye, Trash2 } from 'lucide-vue-next'
|
||||
import { RelationStatus, DiplomaticEventType } from '@/types/game'
|
||||
import type { DiplomaticRelation, NPC } from '@/types/game'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import { formatRelativeTime } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
npc: NPC
|
||||
@@ -229,4 +237,12 @@
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到盟友卡片
|
||||
const scrollToAlly = (allyId: string) => {
|
||||
// 触发父组件的滚动事件
|
||||
// 通过emit通知父组件滚动到指定的NPC卡片
|
||||
const event = new CustomEvent('scrollToNpc', { detail: { npcId: allyId }, bubbles: true })
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
166
src/components/QueueNotifications.vue
Normal file
166
src/components/QueueNotifications.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<Popover v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button data-tutorial="queue-button" variant="outline" size="icon" class="relative">
|
||||
<ListOrdered class="h-4 w-4" />
|
||||
<Badge
|
||||
v-if="totalQueueCount > 0"
|
||||
variant="default"
|
||||
class="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center text-xs"
|
||||
>
|
||||
{{ totalQueueCount > 9 ? '9+' : totalQueueCount }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-96 p-0" align="end">
|
||||
<div class="flex items-center justify-between p-4 border-b">
|
||||
<h3 class="font-semibold">{{ t('queue.title') }}</h3>
|
||||
</div>
|
||||
<ScrollArea class="max-h-96">
|
||||
<div v-if="totalQueueCount === 0" class="p-8 text-center text-muted-foreground">
|
||||
{{ t('queue.empty') }}
|
||||
</div>
|
||||
<div v-else class="divide-y p-4 space-y-3">
|
||||
<!-- 建造队列 -->
|
||||
<div v-for="item in buildQueue" :key="item.id" class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-xs sm:text-sm gap-2">
|
||||
<div class="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-1">
|
||||
<div
|
||||
class="h-2 w-2 rounded-full animate-pulse flex-shrink-0"
|
||||
:class="item.type === 'demolish' ? 'bg-destructive' : 'bg-green-500'"
|
||||
/>
|
||||
<span class="font-medium truncate">{{ getItemName(item) }}</span>
|
||||
<span class="text-muted-foreground text-[10px] sm:text-xs">
|
||||
<template v-if="item.type === 'ship' || item.type === 'defense'">
|
||||
→ {{ t('queue.quantity') }} {{ item.quantity }}
|
||||
</template>
|
||||
<template v-else-if="item.type === 'demolish'">→ {{ t('queue.demolishing') }}</template>
|
||||
<template v-else>→ {{ t('queue.level') }} {{ item.targetLevel }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<span class="text-muted-foreground text-[10px] sm:text-xs whitespace-nowrap">
|
||||
{{ formatTime(getRemainingTime(item)) }}
|
||||
</span>
|
||||
<Button @click="handleCancel(item)" variant="ghost" size="sm" class="h-5 sm:h-6 px-1.5 sm:px-2 text-[10px] sm:text-xs">
|
||||
{{ t('queue.cancel') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Progress :model-value="getQueueProgress(item)" class="h-1.5" />
|
||||
</div>
|
||||
|
||||
<!-- 研究队列 -->
|
||||
<div v-for="item in researchQueue" :key="item.id" class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-xs sm:text-sm gap-2">
|
||||
<div class="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-1">
|
||||
<div class="h-2 w-2 rounded-full bg-blue-500 animate-pulse flex-shrink-0" />
|
||||
<span class="font-medium truncate">{{ getItemName(item) }}</span>
|
||||
<span class="text-muted-foreground text-[10px] sm:text-xs">→ {{ t('queue.level') }} {{ item.targetLevel }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<span class="text-muted-foreground text-[10px] sm:text-xs whitespace-nowrap">
|
||||
{{ formatTime(getRemainingTime(item)) }}
|
||||
</span>
|
||||
<Button
|
||||
@click="handleCancelResearch(item.id)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 sm:h-6 px-1.5 sm:px-2 text-[10px] sm:text-xs"
|
||||
>
|
||||
{{ t('queue.cancel') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Progress :model-value="getQueueProgress(item)" class="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ListOrdered } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { useGameConfig } from '@/composables/useGameConfig'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import type { BuildQueueItem, BuildingType, ShipType, DefenseType, TechnologyType } from '@/types/game'
|
||||
|
||||
const { t } = useI18n()
|
||||
const gameStore = useGameStore()
|
||||
const { BUILDINGS, SHIPS, DEFENSES, TECHNOLOGIES } = useGameConfig()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
// 获取当前星球的建造队列
|
||||
const buildQueue = computed(() => {
|
||||
return gameStore.currentPlanet?.buildQueue || []
|
||||
})
|
||||
|
||||
// 获取研究队列
|
||||
const researchQueue = computed(() => {
|
||||
return gameStore.player.researchQueue || []
|
||||
})
|
||||
|
||||
// 总队列数量
|
||||
const totalQueueCount = computed(() => {
|
||||
return buildQueue.value.length + researchQueue.value.length
|
||||
})
|
||||
|
||||
// 获取队列项名称
|
||||
const getItemName = (item: BuildQueueItem): string => {
|
||||
if (item.type === 'building' || item.type === 'demolish') {
|
||||
return BUILDINGS.value[item.itemType as BuildingType].name
|
||||
} else if (item.type === 'ship') {
|
||||
return SHIPS.value[item.itemType as ShipType].name
|
||||
} else if (item.type === 'defense') {
|
||||
return DEFENSES.value[item.itemType as DefenseType].name
|
||||
} else if (item.type === 'technology') {
|
||||
return TECHNOLOGIES.value[item.itemType as TechnologyType].name
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 获取剩余时间
|
||||
const getRemainingTime = (item: BuildQueueItem): number => {
|
||||
const now = Date.now()
|
||||
return Math.max(0, Math.floor((item.endTime - now) / 1000))
|
||||
}
|
||||
|
||||
// 获取队列进度
|
||||
const getQueueProgress = (item: BuildQueueItem): number => {
|
||||
const now = Date.now()
|
||||
const elapsed = now - item.startTime
|
||||
const total = item.endTime - item.startTime
|
||||
return Math.min(100, (elapsed / total) * 100)
|
||||
}
|
||||
|
||||
// 统一的取消处理
|
||||
const handleCancel = (item: BuildQueueItem) => {
|
||||
let eventName: string
|
||||
if (item.type === 'building' || item.type === 'ship' || item.type === 'defense' || item.type === 'demolish') {
|
||||
eventName = 'cancel-build'
|
||||
} else if (item.type === 'technology') {
|
||||
eventName = 'cancel-research'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
const event = new CustomEvent(eventName, { detail: item.id })
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
// 取消研究
|
||||
const handleCancelResearch = (queueId: string) => {
|
||||
const event = new CustomEvent('cancel-research', { detail: queueId })
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
</script>
|
||||
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>
|
||||
@@ -7,7 +7,7 @@
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex-1 overflow-y-auto min-h-0 mt-4 pr-2">
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none" v-html="renderedMarkdown"></div>
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none" v-html="renderedMarkdown" />
|
||||
</div>
|
||||
|
||||
<DialogFooter class="flex gap-2 flex-shrink-0 mt-4">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
|
||||
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="handleOpenMobileChange">
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
@@ -79,12 +79,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SidebarProps } from '.'
|
||||
import { watch } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
||||
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
|
||||
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
|
||||
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from './utils'
|
||||
import { useTutorial } from '@/composables/useTutorial'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
@@ -96,5 +99,51 @@
|
||||
collapsible: 'offcanvas'
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
const { tutorialState, currentStep, nextStep } = useTutorial()
|
||||
|
||||
// 包装setOpenMobile以拦截教程期间的关闭操作
|
||||
const handleOpenMobileChange = (open: boolean) => {
|
||||
// 如果是移动端且在教程的菜单相关步骤,阻止关闭侧边栏
|
||||
if (tutorialState.value.isActive && currentStep.value) {
|
||||
// 只在第3步期间阻止关闭侧边栏,让玩家必须手动打开
|
||||
if (currentStep.value.id === 'menu_intro_mobile') {
|
||||
// 只允许打开,不允许关闭
|
||||
if (open) {
|
||||
setOpenMobile(true)
|
||||
}
|
||||
// 如果试图关闭,忽略该操作,保持打开状态
|
||||
return
|
||||
}
|
||||
}
|
||||
// 其他情况正常更新
|
||||
setOpenMobile(open)
|
||||
}
|
||||
|
||||
// 监听openMobile变化,在移动端教程第3步时,侧边栏打开后自动推进到第4步
|
||||
watch(
|
||||
() => openMobile.value,
|
||||
(isOpen) => {
|
||||
if (isMobile.value && tutorialState.value.isActive && currentStep.value) {
|
||||
// 如果在第3步且侧边栏刚打开,自动推进到第4步
|
||||
if (currentStep.value.id === 'menu_intro_mobile' && isOpen) {
|
||||
setTimeout(() => {
|
||||
nextStep()
|
||||
}, 300) // 延迟300ms让侧边栏动画完成
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 监听路由变化,在移动端关闭侧边栏
|
||||
watch(
|
||||
() => router.currentRoute.value.path,
|
||||
() => {
|
||||
if (isMobile.value && openMobile.value) {
|
||||
// 路由变化时关闭移动端侧边栏
|
||||
setOpenMobile(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -41,3 +41,9 @@
|
||||
|
||||
const props = defineProps<ToasterProps>()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dark [data-sonner-toast][data-styled='true'] [data-description] {
|
||||
color: oklch(0.91 0 0 / 1);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user