feat: 新增队列与外交通知组件及新手引导

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

View 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>

View File

@@ -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)

View File

@@ -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>

View 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>

View 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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>