feat: 新增多语言README并优化文档结构

新增德语、俄语、韩语、繁体中文多语言README,英文与简体中文README同步优化,统一下载链接与徽章样式,完善多语言入口。提升国际化支持与文档可读性。
This commit is contained in:
谦君
2025-12-24 01:45:17 +08:00
parent a475b1b554
commit 5e3557e2da
105 changed files with 12459 additions and 1690 deletions

View File

@@ -1,363 +0,0 @@
<template>
<Dialog v-model:open="isOpen">
<ScrollableDialogContent container-class="sm:max-w-4xl max-h-[90vh]">
<template #header>
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Trophy class="h-5 w-5" />
{{ t('messagesView.battleReport') }}
</DialogTitle>
<DialogDescription v-if="report">
{{ formatDate(report.timestamp) }}
</DialogDescription>
</DialogHeader>
</template>
<div v-if="report" class="space-y-4">
<!-- 战斗双方信息 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<!-- 攻击方星球 -->
<div class="p-3 bg-blue-50 dark:bg-blue-950/20 rounded-lg">
<p class="font-medium text-blue-600 dark:text-blue-400 mb-1">{{ t('simulatorView.attacker') }}</p>
<p v-if="attackerPlanet" class="text-xs text-muted-foreground">
{{ attackerPlanet.name }} [{{ attackerPlanet.position.galaxy }}:{{ attackerPlanet.position.system }}:{{
attackerPlanet.position.position
}}]
</p>
<p v-else class="text-xs text-muted-foreground">{{ report.attackerPlanetId }}</p>
</div>
<!-- 防守方星球 -->
<div class="p-3 bg-red-50 dark:bg-red-950/20 rounded-lg">
<p class="font-medium text-red-600 dark:text-red-400 mb-1">{{ t('simulatorView.defender') }}</p>
<p v-if="defenderPlanet" class="text-xs text-muted-foreground">
{{ defenderPlanet.name }} [{{ defenderPlanet.position.galaxy }}:{{ defenderPlanet.position.system }}:{{
defenderPlanet.position.position
}}]
</p>
<p v-else class="text-xs text-muted-foreground">{{ report.defenderPlanetId }}</p>
</div>
</div>
<!-- 胜利者 -->
<div class="text-center p-4 rounded-lg" :class="getPlayerResultStyle()">
<p class="text-lg font-bold">
{{ report.winner === 'draw' ? t('messagesView.draw') : isPlayerVictory ? t('messagesView.victory') : t('messagesView.defeat') }}
</p>
<p v-if="report.rounds" class="text-sm mt-1">{{ t('simulatorView.afterRounds').replace('{rounds}', String(report.rounds)) }}</p>
</div>
<!-- 损失对比 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 攻击方损失 -->
<div class="space-y-2">
<p class="text-sm font-medium text-red-600 dark:text-red-400">{{ t('messagesView.attackerLosses') }}</p>
<div class="p-3 bg-muted rounded-lg space-y-1 text-xs">
<div v-for="(count, shipType) in report.attackerLosses" :key="shipType">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
<p v-if="Object.keys(report.attackerLosses).length === 0" class="text-muted-foreground">
{{ t('messagesView.noLosses') }}
</p>
</div>
</div>
<!-- 防守方损失 -->
<div class="space-y-2">
<p class="text-sm font-medium text-red-600 dark:text-red-400">{{ t('messagesView.defenderLosses') }}</p>
<div class="p-3 bg-muted rounded-lg space-y-1 text-xs">
<div v-for="(count, shipType) in report.defenderLosses.fleet" :key="shipType">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
<div v-for="(count, defenseType) in report.defenderLosses.defense" :key="defenseType">
<span class="text-muted-foreground">{{ DEFENSES[defenseType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
<p
v-if="Object.keys(report.defenderLosses.fleet).length === 0 && Object.keys(report.defenderLosses.defense).length === 0"
class="text-muted-foreground"
>
{{ t('messagesView.noLosses') }}
</p>
</div>
</div>
</div>
<!-- 剩余单位 -->
<div v-if="hasAnyRemaining" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 攻击方剩余 -->
<div class="space-y-2">
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">{{ t('messagesView.attackerRemaining') }}</p>
<div class="p-3 bg-muted rounded-lg space-y-1 text-xs">
<template v-if="report.attackerRemaining && Object.keys(report.attackerRemaining).length > 0">
<div v-for="(count, shipType) in report.attackerRemaining" :key="shipType">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
</template>
<p v-else class="text-muted-foreground">{{ t('messagesView.allDestroyed') }}</p>
</div>
</div>
<!-- 防守方剩余 -->
<div class="space-y-2">
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">{{ t('messagesView.defenderRemaining') }}</p>
<div class="p-3 bg-muted rounded-lg space-y-1 text-xs">
<template
v-if="
report.defenderRemaining &&
(Object.keys(report.defenderRemaining.fleet || {}).length > 0 ||
Object.keys(report.defenderRemaining.defense || {}).length > 0)
"
>
<div v-for="(count, shipType) in report.defenderRemaining.fleet" :key="shipType">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
<div v-for="(count, defenseType) in report.defenderRemaining.defense" :key="defenseType">
<span class="text-muted-foreground">{{ DEFENSES[defenseType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
</template>
<p v-else class="text-muted-foreground">{{ t('messagesView.allDestroyed') }}</p>
</div>
</div>
</div>
<!-- 掠夺资源 -->
<div
v-if="report.plunder && (report.plunder.metal > 0 || report.plunder.crystal > 0 || report.plunder.deuterium > 0)"
class="p-3 bg-green-50 dark:bg-green-950 rounded-lg"
>
<p class="text-sm font-medium mb-2 text-green-600 dark:text-green-400">{{ t('messagesView.plunder') }}</p>
<div class="flex flex-wrap gap-3 text-xs justify-center">
<span v-if="report.plunder.metal > 0" class="flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ formatNumber(report.plunder.metal) }}
</span>
<span v-if="report.plunder.crystal > 0" class="flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ formatNumber(report.plunder.crystal) }}
</span>
<span v-if="report.plunder.deuterium > 0" class="flex items-center gap-1">
<ResourceIcon type="deuterium" size="sm" />
{{ formatNumber(report.plunder.deuterium) }}
</span>
</div>
</div>
<!-- 残骸场 -->
<div
v-if="report.debrisField && (report.debrisField.metal > 0 || report.debrisField.crystal > 0)"
class="text-center p-4 bg-muted rounded-lg"
>
<p class="text-sm font-medium mb-2">{{ t('messagesView.debrisField') }}</p>
<div class="flex flex-wrap gap-3 text-xs justify-center">
<span v-if="report.debrisField.metal > 0" class="flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ formatNumber(report.debrisField.metal) }}
</span>
<span v-if="report.debrisField.crystal > 0" class="flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ formatNumber(report.debrisField.crystal) }}
</span>
</div>
<!-- 月球生成概率 -->
<p v-if="report.moonChance && report.moonChance > 0" class="text-xs text-muted-foreground mt-2">
{{ t('messagesView.moonChance') }}: {{ (report.moonChance * 100).toFixed(1) }}%
</p>
</div>
<!-- 回合详情 -->
<div v-if="report.roundDetails && report.roundDetails.length > 0" class="space-y-2">
<Button @click="showRoundDetails = !showRoundDetails" variant="outline" size="sm" class="w-full">
{{ showRoundDetails ? t('messagesView.hideRoundDetails') : t('messagesView.showRoundDetails') }}
</Button>
<div v-if="showRoundDetails" class="relative pl-6 space-y-4">
<!-- 时间线 -->
<div class="absolute left-2 top-0 bottom-0 w-0.5 bg-border" />
<div v-for="detail in report.roundDetails" :key="detail.round" class="relative">
<!-- 时间线节点 -->
<div class="absolute -left-6 top-3 w-4 h-4 rounded-full bg-primary border-2 border-background" />
<!-- 回合内容卡片 -->
<div class="border rounded-lg p-3 bg-card hover:shadow-md transition-shadow">
<div class="flex items-center justify-between mb-3">
<p class="text-sm font-semibold">{{ t('messagesView.round').replace('{round}', String(detail.round)) }}</p>
<TooltipProvider :delay-duration="300">
<div class="flex gap-3 text-xs text-muted-foreground">
<Tooltip>
<TooltipTrigger as-child>
<span class="flex items-center gap-1">
<Sword class="h-3 w-3" />
{{ formatNumber(detail.attackerRemainingPower) }}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('messagesView.attackerRemainingPower') }}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<span class="flex items-center gap-1">
<Shield class="h-3 w-3" />
{{ formatNumber(detail.defenderRemainingPower) }}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('messagesView.defenderRemainingPower') }}</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<!-- 攻击方本回合损失 -->
<div class="bg-red-50 dark:bg-red-950/20 rounded p-2">
<p class="text-xs font-medium text-red-600 dark:text-red-400 mb-1.5">{{ t('messagesView.attackerLosses') }}</p>
<div class="text-xs space-y-0.5">
<div v-for="(count, shipType) in detail.attackerLosses" :key="shipType" class="flex justify-between">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}</span>
<span class="font-medium">-{{ count }}</span>
</div>
<p v-if="Object.keys(detail.attackerLosses).length === 0" class="text-muted-foreground italic">
{{ t('messagesView.noLosses') }}
</p>
</div>
</div>
<!-- 防守方本回合损失 -->
<div class="bg-blue-50 dark:bg-blue-950/20 rounded p-2">
<p class="text-xs font-medium text-blue-600 dark:text-blue-400 mb-1.5">{{ t('messagesView.defenderLosses') }}</p>
<div class="text-xs space-y-0.5">
<div v-for="(count, shipType) in detail.defenderLosses.fleet" :key="shipType" class="flex justify-between">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}</span>
<span class="font-medium">-{{ count }}</span>
</div>
<div v-for="(count, defenseType) in detail.defenderLosses.defense" :key="defenseType" class="flex justify-between">
<span class="text-muted-foreground">{{ DEFENSES[defenseType].name }}</span>
<span class="font-medium">-{{ count }}</span>
</div>
<p
v-if="
Object.keys(detail.defenderLosses.fleet).length === 0 && Object.keys(detail.defenderLosses.defense).length === 0
"
class="text-muted-foreground italic"
>
{{ t('messagesView.noLosses') }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</ScrollableDialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useGameStore } from '@/stores/gameStore'
import { useUniverseStore } from '@/stores/universeStore'
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { Dialog, ScrollableDialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import ResourceIcon from '@/components/ResourceIcon.vue'
import { formatNumber, formatDate } from '@/utils/format'
import { Trophy, Sword, Shield } from 'lucide-vue-next'
import type { BattleResult } from '@/types/game'
const props = defineProps<{
report: BattleResult | null
open: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const gameStore = useGameStore()
const universeStore = useUniverseStore()
const { t } = useI18n()
const { SHIPS, DEFENSES } = useGameConfig()
const isOpen = ref(props.open)
const showRoundDetails = ref(false)
// 获取攻击方星球信息
const attackerPlanet = computed(() => {
if (!props.report) return null
// 先从玩家星球中查找
const playerPlanet = gameStore.player.planets.find(p => p.id === props.report!.attackerPlanetId)
if (playerPlanet) return playerPlanet
// 再从宇宙星球地图中查找(包括 NPC 星球)
return Object.values(universeStore.planets).find(p => p.id === props.report!.attackerPlanetId)
})
// 获取防守方星球信息
const defenderPlanet = computed(() => {
if (!props.report) return null
// 先从玩家星球中查找
const playerPlanet = gameStore.player.planets.find(p => p.id === props.report!.defenderPlanetId)
if (playerPlanet) return playerPlanet
// 再从宇宙星球地图中查找(包括 NPC 星球)
return Object.values(universeStore.planets).find(p => p.id === props.report!.defenderPlanetId)
})
// 判断玩家是攻击方还是防守方
const isPlayerAttacker = computed(() => {
if (!props.report) return false
return gameStore.player.planets.some(p => p.id === props.report!.attackerPlanetId)
})
// 判断玩家是否胜利
const isPlayerVictory = computed(() => {
if (!props.report) return false
if (props.report.winner === 'draw') return false
// 玩家是攻击方且攻击方胜利,或者玩家是防守方且防守方胜利
return (isPlayerAttacker.value && props.report.winner === 'attacker') || (!isPlayerAttacker.value && props.report.winner === 'defender')
})
// 判断是否有任何剩余单位需要显示
const hasAnyRemaining = computed(() => {
if (!props.report) return false
const hasAttackerRemaining = props.report.attackerRemaining && Object.keys(props.report.attackerRemaining).length > 0
const hasDefenderRemaining =
props.report.defenderRemaining &&
(Object.keys(props.report.defenderRemaining.fleet || {}).length > 0 ||
Object.keys(props.report.defenderRemaining.defense || {}).length > 0)
return hasAttackerRemaining || hasDefenderRemaining
})
watch(
() => props.open,
newValue => {
isOpen.value = newValue
if (newValue) {
showRoundDetails.value = false
}
}
)
watch(isOpen, newValue => {
emit('update:open', newValue)
})
// 获取玩家战斗结果样式
const getPlayerResultStyle = () => {
if (!props.report) return 'bg-gray-50 dark:bg-gray-950 text-gray-700 dark:text-gray-300'
if (props.report.winner === 'draw') return 'bg-gray-50 dark:bg-gray-950 text-gray-700 dark:text-gray-300'
if (isPlayerVictory.value) return 'bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300'
return 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300'
}
</script>

View File

@@ -1,201 +0,0 @@
<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 }}
</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') }} ({{ totalQueueCount }})</h3>
</div>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="w-full grid grid-cols-5 h-9 rounded-none border-b bg-transparent">
<TabsTrigger v-for="tab in tabConfig" :key="tab.value" :value="tab.value" class="text-xs px-1 data-[state=active]:bg-muted">
{{ t(`queue.tabs.${tab.value}`) }}
<Badge v-if="tab.items.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
{{ tab.items.length }}
</Badge>
</TabsTrigger>
</TabsList>
<ScrollArea class="h-[420px]">
<TabsContent v-for="tab in tabConfig" :key="tab.value" :value="tab.value" class="mt-0">
<Empty v-if="tab.items.length === 0" class="border-0">
<EmptyContent>
<Inbox class="h-10 w-10 text-muted-foreground" />
<EmptyDescription>{{ t('queue.empty') }}</EmptyDescription>
</EmptyContent>
</Empty>
<div v-else class="divide-y p-4 space-y-3">
<div v-for="item in tab.items" :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="getStatusDotClass(item)" />
<span class="font-medium truncate">{{ getItemName(item) }}</span>
<span class="text-muted-foreground text-[10px] sm:text-xs">
{{
item.type === 'ship' || item.type === 'defense'
? `${t('queue.quantity')} ${item.quantity}`
: item.type === 'demolish'
? `${t('queue.demolishing')}`
: `${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
variant="ghost"
size="sm"
class="h-5 sm:h-6 px-1.5 sm:px-2 text-[10px] sm:text-xs"
@click.stop="handleCancel(item)"
>
{{ t('queue.cancel') }}
</Button>
</div>
</div>
<Progress :model-value="getQueueProgress(item)" class="h-1.5" />
</div>
</div>
</TabsContent>
</ScrollArea>
</Tabs>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { computed, ref, onUnmounted, watch } from 'vue'
import { ListOrdered, Inbox } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Empty, EmptyContent, EmptyDescription } from '@/components/ui/empty'
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 activeTab = ref('all')
// 响应式时间戳,用于驱动时间和进度的动态更新
const currentTime = ref(Date.now())
let timerInterval: ReturnType<typeof setInterval> | null = null
// 当弹窗打开时启动计时器,关闭时停止
watch(isOpen, open => {
if (open) {
// 启动每秒更新的计时器
timerInterval = setInterval(() => {
currentTime.value = Date.now()
}, 1000)
} else {
// 停止计时器
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
}
})
// 组件卸载时清理计时器
onUnmounted(() => {
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
})
// 获取当前星球的建造队列
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 tabConfig = computed(() => [
{ value: 'all', items: [...buildQueue.value, ...researchQueue.value] },
{ value: 'buildings', items: buildQueue.value.filter(item => item.type === 'building' || item.type === 'demolish') },
{ value: 'research', items: researchQueue.value },
{ value: 'ships', items: buildQueue.value.filter(item => item.type === 'ship') },
{ value: 'defense', items: buildQueue.value.filter(item => item.type === 'defense') }
])
// 获取队列项名称
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 ''
}
// 获取剩余时间(使用响应式 currentTime 确保动态更新)
const getRemainingTime = (item: BuildQueueItem): number => {
return Math.max(0, Math.floor((item.endTime - currentTime.value) / 1000))
}
// 获取队列进度(使用响应式 currentTime 确保动态更新)
const getQueueProgress = (item: BuildQueueItem): number => {
const elapsed = currentTime.value - item.startTime
const total = item.endTime - item.startTime
if (total <= 0) return 100
return Math.max(0, 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 getStatusDotClass = (item: BuildQueueItem): string => {
if (item.type === 'demolish') return 'bg-destructive'
if (item.type === 'technology') return 'bg-blue-500'
return 'bg-green-500'
}
</script>

View File

@@ -1,129 +0,0 @@
<template>
<Dialog v-model:open="isOpen">
<ScrollableDialogContent container-class="sm:max-w-2xl max-h-[90vh]">
<template #header>
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Eye class="h-5 w-5" />
{{ t('messagesView.spyReport') }}
</DialogTitle>
<DialogDescription v-if="report">
{{ formatDate(report.timestamp) }}
</DialogDescription>
</DialogHeader>
</template>
<div v-if="report" class="space-y-4">
<!-- 目标星球信息 -->
<div class="p-3 bg-muted rounded-lg">
<p class="text-sm font-medium mb-2">{{ t('messagesView.targetPlanet') }}</p>
<p class="text-xs text-muted-foreground">
{{ report.targetPlanetName }} [{{ report.targetPosition.galaxy }}:{{ report.targetPosition.system }}:{{
report.targetPosition.position
}}]
</p>
</div>
<!-- 资源 -->
<div>
<p class="text-sm font-medium mb-2">{{ t('messagesView.resources') }}:</p>
<div class="flex flex-wrap gap-3 text-xs sm:text-sm">
<span class="flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ formatNumber(report.resources.metal) }}
</span>
<span class="flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ formatNumber(report.resources.crystal) }}
</span>
<span class="flex items-center gap-1">
<ResourceIcon type="deuterium" size="sm" />
{{ formatNumber(report.resources.deuterium) }}
</span>
<span class="flex items-center gap-1">
<ResourceIcon type="darkMatter" size="sm" />
{{ formatNumber(report.resources.darkMatter) }}
</span>
</div>
</div>
<!-- 舰队如果有 -->
<div v-if="report.fleet && Object.keys(report.fleet).length > 0">
<p class="text-sm font-medium mb-2">{{ t('messagesView.fleet') }}:</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2 text-xs sm:text-sm">
<div v-for="(count, shipType) in report.fleet" :key="shipType">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-1 font-medium">{{ count }}</span>
</div>
</div>
</div>
<!-- 防御设施如果有 -->
<div v-if="report.defense && hasDefense(report.defense)">
<p class="text-sm font-medium mb-2">{{ t('messagesView.defense') }}:</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2 text-xs sm:text-sm">
<div v-for="(count, defenseType) in report.defense" :key="defenseType">
<span v-if="count && count > 0" class="block">
<span class="text-muted-foreground">{{ DEFENSES[defenseType].name }}:</span>
<span class="ml-1 font-medium">{{ count }}</span>
</span>
</div>
</div>
</div>
<!-- 建筑如果有 -->
<div v-if="report.buildings && Object.keys(report.buildings).length > 0">
<p class="text-sm font-medium mb-2">{{ t('messagesView.buildings') }}:</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2 text-xs sm:text-sm">
<div v-for="(level, buildingType) in report.buildings" :key="buildingType">
<span class="text-muted-foreground">{{ BUILDINGS[buildingType].name }}:</span>
<span class="ml-1 font-medium">Lv.{{ level }}</span>
</div>
</div>
</div>
</div>
</ScrollableDialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { Dialog, ScrollableDialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import ResourceIcon from '@/components/ResourceIcon.vue'
import { formatNumber, formatDate } from '@/utils/format'
import { Eye } from 'lucide-vue-next'
import type { SpyReport } from '@/types/game'
const props = defineProps<{
report: SpyReport | null
open: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const { t } = useI18n()
const { SHIPS, DEFENSES, BUILDINGS } = useGameConfig()
const isOpen = ref(props.open)
watch(
() => props.open,
newValue => {
isOpen.value = newValue
}
)
watch(isOpen, newValue => {
emit('update:open', newValue)
})
// 检查是否有防御设施
const hasDefense = (defense: any): boolean => {
if (!defense) return false
return Object.values(defense).some((count: any) => count > 0)
}
</script>

View File

@@ -0,0 +1,405 @@
<template>
<div class="quest-map-container relative">
<!-- 可缩放的地图区域 -->
<div
ref="mapContainer"
class="quest-map relative overflow-auto rounded-lg border bg-card/50 backdrop-blur-sm"
:style="{ maxHeight: '450px' }"
>
<!-- 可缩放内容包装器 -->
<div
class="map-content origin-top-left transition-transform duration-200"
:style="{ transform: `scale(${zoomLevel})`, minWidth: calculatedMapWidth + 'px', minHeight: calculatedMapHeight + 'px' }"
>
<!-- SVG连接线 - 位置与节点容器对齐 -->
<svg
class="absolute pointer-events-none"
:style="{ left: 0, top: 0, width: calculatedMapWidth + 'px', height: calculatedMapHeight + 'px' }"
:viewBox="`0 0 ${calculatedMapWidth} ${calculatedMapHeight}`"
>
<defs>
<!-- 渐变定义 - 垂直方向 -->
<linearGradient id="line-gradient-active" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color: hsl(var(--primary)); stop-opacity: 0.5" />
<stop offset="100%" style="stop-color: hsl(var(--primary)); stop-opacity: 1" />
</linearGradient>
<linearGradient id="line-gradient-locked" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color: hsl(var(--muted-foreground)); stop-opacity: 0.2" />
<stop offset="100%" style="stop-color: hsl(var(--muted-foreground)); stop-opacity: 0.3" />
</linearGradient>
<linearGradient id="line-gradient-completed" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color: rgb(34, 197, 94); stop-opacity: 0.5" />
<stop offset="100%" style="stop-color: rgb(34, 197, 94); stop-opacity: 1" />
</linearGradient>
<!-- 发光滤镜 -->
<filter id="glow">
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- 连接线 -->
<g>
<template v-for="connection in questConnections" :key="connection.id">
<path
:d="connection.path"
fill="none"
:stroke="getConnectionStroke(connection)"
stroke-width="2"
:stroke-dasharray="connection.isLocked ? '5,5' : 'none'"
:filter="connection.isActive ? 'url(#glow)' : 'none'"
class="transition-all duration-300"
/>
<!-- 流动动画点激活状态 -->
<circle v-if="connection.isActive" r="3" :fill="'hsl(var(--primary))'" class="animate-flow">
<animateMotion :dur="'2s'" repeatCount="indefinite" :path="connection.path" />
</circle>
</template>
</g>
</svg>
<!-- 任务节点 -->
<div class="relative" :style="{ width: calculatedMapWidth + 'px', height: calculatedMapHeight + 'px' }">
<div v-for="quest in quests" :key="quest.id" class="absolute transition-all duration-300" :style="getNodeStyle(quest.id)">
<QuestNode :quest="quest" :progress="progress" @select="handleQuestSelect" />
</div>
</div>
</div>
</div>
<!-- 地图控制 -->
<div class="absolute bottom-4 right-4 flex gap-2">
<Button variant="outline" size="icon-sm" @click="zoomIn">
<ZoomIn class="h-4 w-4" />
</Button>
<Button variant="outline" size="icon-sm" @click="zoomOut">
<ZoomOut class="h-4 w-4" />
</Button>
<Button variant="outline" size="icon-sm" @click="resetView">
<Maximize2 class="h-4 w-4" />
</Button>
</div>
<!-- 图例 -->
<div class="absolute top-4 left-4 flex flex-wrap gap-3 text-xs">
<div class="flex items-center gap-1">
<div class="w-3 h-3 rounded-full bg-green-500" />
<span class="text-muted-foreground">{{ t('campaign.completed') }}</span>
</div>
<div class="flex items-center gap-1">
<div class="w-3 h-3 rounded-full bg-primary" />
<span class="text-muted-foreground">{{ t('campaign.inProgress') }}</span>
</div>
<div class="flex items-center gap-1">
<div class="w-3 h-3 rounded-full bg-blue-400 animate-pulse" />
<span class="text-muted-foreground">{{ t('campaign.available') }}</span>
</div>
<div class="flex items-center gap-1">
<div class="w-3 h-3 rounded-full bg-muted-foreground/30" />
<span class="text-muted-foreground">{{ t('campaign.locked') }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { useI18n } from '@/composables/useI18n'
import { QuestStatus, type CampaignQuestConfig, type PlayerCampaignProgress } from '@/types/game'
import { getQuestStatus } from '@/logic/campaignLogic'
import { Button } from '@/components/ui/button'
import QuestNode from './QuestNode.vue'
import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-vue-next'
const props = defineProps<{
quests: CampaignQuestConfig[]
progress: PlayerCampaignProgress | undefined
}>()
const emit = defineEmits<{
selectQuest: [questId: string]
}>()
const { t } = useI18n()
// 地图容器引用
const mapContainer = ref<HTMLElement | null>(null)
// 布局参数 - 从左到右的工作流布局
const nodeSize = 56 // 节点实际尺寸 (w-14 = 56px)
const nodeRadius = 28 // 节点半径
const horizontalGap = 120 // 水平间距(层级之间,包含连线空间)
const verticalGap = 40 // 垂直间距(同一层级内)
const paddingX = 80
const paddingY = 60
// 缩放级别
const zoomLevel = ref(1)
// 计算工作流布局的节点位置(从左到右)
const nodePositions = computed(() => {
const positions: Record<string, { x: number; y: number; level: number; index: number }> = {}
const levels: Record<number, CampaignQuestConfig[]> = {}
// 根据任务的依赖关系计算层级
const calculateLevel = (quest: CampaignQuestConfig, visited: Set<string> = new Set()): number => {
if (visited.has(quest.id)) return 0
visited.add(quest.id)
if (!quest.requiredQuestIds || quest.requiredQuestIds.length === 0) {
return 0
}
let maxParentLevel = -1
quest.requiredQuestIds.forEach(reqId => {
const parentQuest = props.quests.find(q => q.id === reqId)
if (parentQuest) {
const parentLevel = calculateLevel(parentQuest, visited)
maxParentLevel = Math.max(maxParentLevel, parentLevel)
}
})
return maxParentLevel + 1
}
// 为每个任务计算层级
props.quests.forEach(quest => {
const level = calculateLevel(quest)
if (!levels[level]) {
levels[level] = []
}
levels[level].push(quest)
})
// 按 order 排序每个层级的任务
Object.keys(levels).forEach(levelKey => {
const level = parseInt(levelKey)
const questsAtLevel = levels[level]
if (questsAtLevel) {
questsAtLevel.sort((a, b) => a.order - b.order)
}
})
// 计算每个任务的位置(从左到右布局)
const levelKeys = Object.keys(levels)
.map(Number)
.sort((a, b) => a - b)
levelKeys.forEach(level => {
const questsInLevel = levels[level]
if (!questsInLevel) return
questsInLevel.forEach((quest, index) => {
// 水平位置层级决定X坐标
const x = paddingX + level * (nodeSize + horizontalGap) + nodeRadius
// 垂直位置同层级内的索引决定Y坐标
const startY = paddingY + index * (nodeSize + verticalGap)
const y = startY + nodeRadius
positions[quest.id] = { x, y, level, index }
})
})
return positions
})
// 计算地图尺寸
const calculatedMapWidth = computed(() => {
const positions = Object.values(nodePositions.value)
if (positions.length === 0) return 400
const maxX = Math.max(...positions.map(p => p.x))
return Math.max(maxX + paddingX + nodeRadius, 400)
})
const calculatedMapHeight = computed(() => {
const positions = Object.values(nodePositions.value)
if (positions.length === 0) return 300
const maxY = Math.max(...positions.map(p => p.y))
return Math.max(maxY + paddingY + nodeRadius, 300)
})
// 计算连接线
interface Connection {
id: string
from: string
to: string
path: string
isLocked: boolean
isActive: boolean
isCompleted: boolean
}
const questConnections = computed<Connection[]>(() => {
const connections: Connection[] = []
props.quests.forEach(quest => {
if (quest.requiredQuestIds) {
quest.requiredQuestIds.forEach(requiredId => {
const fromPos = nodePositions.value[requiredId]
const toPos = nodePositions.value[quest.id]
if (fromPos && toPos) {
// 从节点右边缘出发,到下一个节点左边缘
const startX = fromPos.x + nodeRadius
const startY = fromPos.y
const endX = toPos.x - nodeRadius
const endY = toPos.y
// 使用水平控制点创建平滑的S型曲线
const controlOffset = (endX - startX) / 2
const path = `M ${startX} ${startY} C ${startX + controlOffset} ${startY}, ${endX - controlOffset} ${endY}, ${endX} ${endY}`
// 获取状态
const fromQuest = props.quests.find(q => q.id === requiredId)
const fromStatus = props.progress && fromQuest ? getQuestStatus(props.progress, fromQuest.id) : QuestStatus.Locked
const toStatus = props.progress ? getQuestStatus(props.progress, quest.id) : QuestStatus.Locked
connections.push({
id: `${requiredId}-${quest.id}`,
from: requiredId,
to: quest.id,
path,
isLocked: toStatus === QuestStatus.Locked,
isActive: toStatus === QuestStatus.Active || toStatus === QuestStatus.Available,
isCompleted: fromStatus === QuestStatus.Completed && toStatus === QuestStatus.Completed
})
}
})
}
})
return connections
})
// 获取连接线颜色
const getConnectionStroke = (connection: Connection): string => {
if (connection.isCompleted) {
return 'rgb(34, 197, 94)'
}
if (connection.isActive) {
return 'hsl(var(--primary))'
}
// 锁定状态使用更明显的灰色
return 'rgba(156, 163, 175, 0.5)'
}
// 获取节点样式(处理 undefined 情况)
const getNodeStyle = (questId: string) => {
const pos = nodePositions.value[questId]
if (!pos) {
return { left: '0px', top: '0px' }
}
return {
left: pos.x - nodeRadius + 'px',
top: pos.y - nodeRadius + 'px'
}
}
// 缩放控制
const zoomIn = () => {
zoomLevel.value = Math.min(zoomLevel.value * 1.2, 2)
}
const zoomOut = () => {
zoomLevel.value = Math.max(zoomLevel.value / 1.2, 0.5)
}
const resetView = () => {
zoomLevel.value = 1
if (mapContainer.value) {
mapContainer.value.scrollTo({ left: 0, top: 0, behavior: 'smooth' })
}
}
// 处理任务选择
const handleQuestSelect = (questId: string) => {
emit('selectQuest', questId)
}
// 找到当前活动或可用的任务
const findActiveQuest = (): string | null => {
// 优先找 Active 状态的任务
const activeQuest = props.quests.find(quest => {
if (!props.progress) return false
return getQuestStatus(props.progress, quest.id) === QuestStatus.Active
})
if (activeQuest) return activeQuest.id
// 其次找第一个 Available 状态的任务
const availableQuest = props.quests.find(quest => {
if (!props.progress) return false
return getQuestStatus(props.progress, quest.id) === QuestStatus.Available
})
if (availableQuest) return availableQuest.id
return null
}
// 滚动到指定任务位置(居中显示)
const scrollToQuest = (questId: string) => {
const container = mapContainer.value
if (!container) return
const pos = nodePositions.value[questId]
if (!pos) return
// 计算需要滚动的位置,使任务节点居中
const containerWidth = container.clientWidth
const containerHeight = container.clientHeight
// 考虑缩放比例
const scaledX = pos.x * zoomLevel.value
const scaledY = pos.y * zoomLevel.value
// 滚动到节点居中的位置
const scrollLeft = Math.max(0, scaledX - containerWidth / 2)
const scrollTop = Math.max(0, scaledY - containerHeight / 2)
container.scrollTo({
left: scrollLeft,
top: scrollTop,
behavior: 'smooth'
})
}
// 组件挂载时滚动到活动任务
onMounted(async () => {
await nextTick()
const activeQuestId = findActiveQuest()
if (activeQuestId) {
scrollToQuest(activeQuestId)
}
})
</script>
<style scoped>
.quest-map-container {
position: relative;
}
.quest-map {
min-height: 300px;
}
.animate-flow {
filter: drop-shadow(0 0 3px hsl(var(--primary)));
}
/* 星空背景效果 */
.quest-map::before {
content: '';
position: absolute;
inset: 0;
background-image: radial-gradient(circle at 20% 30%, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
radial-gradient(circle at 80% 70%, rgba(255, 255, 255, 0.02) 1px, transparent 1px),
radial-gradient(circle at 40% 80%, rgba(255, 255, 255, 0.02) 1px, transparent 1px),
radial-gradient(circle at 60% 20%, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 100px 100px, 150px 150px, 200px 200px, 120px 120px;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,195 @@
<template>
<div
class="quest-node"
:class="[statusClass, { 'cursor-pointer': isClickable, 'cursor-not-allowed': !isClickable }]"
@click="handleClick"
>
<!-- 节点主体 -->
<div
:class="[
'relative w-14 h-14 rounded-full flex items-center justify-center transition-all duration-300',
'border-2 shadow-lg',
nodeBackgroundClass
]"
>
<!-- Boss标记 -->
<div v-if="quest.isBoss" class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center">
<Skull class="w-3 h-3 text-white" />
</div>
<!-- 分支标记 -->
<div v-if="quest.isBranch" class="absolute -top-1 -left-1 w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center">
<GitBranch class="w-3 h-3 text-white" />
</div>
<!-- 状态图标 -->
<component :is="statusIcon" :class="['w-6 h-6', iconClass]" />
<!-- 进度环 -->
<svg v-if="status === QuestStatus.Active && progress > 0" class="absolute inset-0 w-full h-full -rotate-90" viewBox="0 0 56 56">
<circle cx="28" cy="28" r="24" fill="none" stroke="currentColor" stroke-width="3" class="text-primary/30" />
<circle
cx="28"
cy="28"
r="24"
fill="none"
stroke="currentColor"
stroke-width="3"
class="text-primary"
:stroke-dasharray="`${progress * 1.51} 151`"
/>
</svg>
</div>
<!-- 节点标题 -->
<div class="mt-2 text-center max-w-20">
<p :class="['text-xs font-medium truncate', titleClass]">
{{ t(quest.titleKey) }}
</p>
<p v-if="status === QuestStatus.Active" class="text-[10px] text-primary">{{ progress }}%</p>
</div>
<!-- 脉冲动画可用状态 -->
<div v-if="status === QuestStatus.Available" class="absolute inset-0 w-14 h-14 rounded-full animate-ping bg-blue-400/30" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@/composables/useI18n'
import { QuestStatus, type CampaignQuestConfig, type PlayerCampaignProgress } from '@/types/game'
import { getQuestStatus, calculateQuestProgress } from '@/logic/campaignLogic'
import { Lock, Circle, CheckCircle2, Play, Skull, GitBranch } from 'lucide-vue-next'
const props = defineProps<{
quest: CampaignQuestConfig
progress: PlayerCampaignProgress | undefined
}>()
const emit = defineEmits<{
select: [questId: string]
}>()
const { t } = useI18n()
// 计算任务状态
const status = computed(() => {
if (!props.progress) return QuestStatus.Locked
return getQuestStatus(props.progress, props.quest.id)
})
// 计算任务进度百分比
const progress = computed(() => {
if (!props.progress || status.value !== QuestStatus.Active) return 0
return calculateQuestProgress(props.progress, props.quest.id)
})
// 是否可点击
const isClickable = computed(() => {
return status.value !== QuestStatus.Locked
})
// 状态样式类
const statusClass = computed(() => {
switch (status.value) {
case QuestStatus.Completed:
return 'quest-completed'
case QuestStatus.Active:
return 'quest-active'
case QuestStatus.Available:
return 'quest-available'
default:
return 'quest-locked'
}
})
// 节点背景样式
const nodeBackgroundClass = computed(() => {
switch (status.value) {
case QuestStatus.Completed:
return 'bg-green-500/20 border-green-500'
case QuestStatus.Active:
return 'bg-primary/20 border-primary'
case QuestStatus.Available:
return 'bg-blue-500/10 border-blue-400 hover:border-blue-300 hover:bg-blue-500/20'
default:
return 'bg-muted/50 border-muted-foreground/30'
}
})
// 状态图标
const statusIcon = computed(() => {
switch (status.value) {
case QuestStatus.Completed:
return CheckCircle2
case QuestStatus.Active:
return Play
case QuestStatus.Available:
return Circle
default:
return Lock
}
})
// 图标样式
const iconClass = computed(() => {
switch (status.value) {
case QuestStatus.Completed:
return 'text-green-500'
case QuestStatus.Active:
return 'text-primary'
case QuestStatus.Available:
return 'text-blue-400'
default:
return 'text-muted-foreground/50'
}
})
// 标题样式
const titleClass = computed(() => {
switch (status.value) {
case QuestStatus.Completed:
return 'text-green-500'
case QuestStatus.Active:
return 'text-primary'
case QuestStatus.Available:
return 'text-foreground'
default:
return 'text-muted-foreground/50'
}
})
// 处理点击
const handleClick = () => {
if (isClickable.value) {
emit('select', props.quest.id)
}
}
</script>
<style scoped>
.quest-node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.quest-available .quest-node:hover {
transform: scale(1.05);
}
@keyframes pulse-glow {
0%,
100% {
box-shadow: 0 0 5px 2px rgba(59, 130, 246, 0.3);
}
50% {
box-shadow: 0 0 15px 5px rgba(59, 130, 246, 0.5);
}
}
.quest-available > div:first-child {
animation: pulse-glow 2s ease-in-out infinite;
}
</style>

View File

@@ -0,0 +1,271 @@
<template>
<Dialog :open="true" @update:open="handleClose">
<DialogContent class="max-w-2xl p-0 overflow-hidden bg-gradient-to-b from-background to-background/95">
<!-- 对话框头部 - 星空效果 -->
<div class="absolute inset-0 pointer-events-none overflow-hidden">
<div class="stars-bg" />
</div>
<!-- 对话内容区 -->
<div class="relative p-6 min-h-[300px] flex flex-col">
<!-- 说话者信息 -->
<div v-if="currentDialogue" class="mb-4">
<div class="flex items-center gap-3">
<!-- 头像 -->
<div :class="['w-12 h-12 rounded-full flex items-center justify-center', speakerStyles.bg]">
<component :is="speakerIcon" :class="['w-6 h-6', speakerStyles.text]" />
</div>
<!-- 说话者名称 -->
<div>
<span :class="['font-semibold', speakerStyles.text]">
{{ getSpeakerName(currentDialogue) }}
</span>
<div v-if="currentDialogue.speaker === 'mysterious'" class="text-xs text-muted-foreground">
{{ t('campaign.dialogue.unknownSource') }}
</div>
</div>
</div>
</div>
<!-- 对话文本 - 打字机效果 -->
<div class="flex-1 min-h-[120px]">
<div class="text-base leading-relaxed">
<span>{{ displayedText }}</span>
<span v-if="isTyping" class="animate-pulse"></span>
</div>
</div>
<!-- 选项按钮 -->
<div v-if="showChoices && currentDialogue?.choices" class="space-y-2 mt-4">
<Button
v-for="(choice, index) in currentDialogue.choices"
:key="index"
variant="outline"
class="w-full justify-start text-left h-auto py-3 px-4"
@click="handleChoice(choice)"
>
<ChevronRight class="w-4 h-4 mr-2 shrink-0" />
<span>{{ t(choice.textKey) }}</span>
</Button>
</div>
<!-- 继续按钮 -->
<div v-if="!showChoices" class="mt-4 flex justify-end gap-2">
<Button v-if="isTyping" variant="ghost" size="sm" @click="skipTyping">
{{ t('campaign.dialogue.skip') }}
</Button>
<Button v-else @click="handleContinue" :disabled="isTyping">
{{ isLastDialogue ? t('campaign.dialogue.finish') : t('campaign.dialogue.continue') }}
<ChevronRight class="w-4 h-4 ml-1" />
</Button>
</div>
<!-- 进度指示器 -->
<div class="mt-4 flex justify-center gap-1">
<div
v-for="(_, index) in dialogues"
:key="index"
:class="[
'w-2 h-2 rounded-full transition-all',
index < currentIndex ? 'bg-primary' : index === currentIndex ? 'bg-primary/50' : 'bg-muted'
]"
/>
</div>
</div>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useI18n } from '@/composables/useI18n'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import type { StoryDialogue, DialogueChoice } from '@/types/game'
import { User, Bot, HelpCircle, MessageCircle, ChevronRight } from 'lucide-vue-next'
const props = defineProps<{
dialogues: StoryDialogue[]
}>()
const emit = defineEmits<{
close: []
choice: [choice: DialogueChoice]
}>()
const { t } = useI18n()
// 当前对话索引
const currentIndex = ref(0)
// 打字机效果状态
const displayedText = ref('')
const isTyping = ref(false)
const typewriterInterval = ref<ReturnType<typeof setInterval> | null>(null)
// 当前对话
const currentDialogue = computed(() => props.dialogues[currentIndex.value])
// 是否是最后一个对话
const isLastDialogue = computed(() => currentIndex.value >= props.dialogues.length - 1)
// 是否显示选项
const showChoices = computed(() => {
return !isTyping.value && currentDialogue.value?.choices && currentDialogue.value.choices.length > 0
})
// 说话者图标
const speakerIcon = computed(() => {
switch (currentDialogue.value?.speaker) {
case 'player':
return User
case 'npc':
return Bot
case 'mysterious':
return HelpCircle
default:
return MessageCircle
}
})
// 说话者样式
const speakerStyles = computed(() => {
switch (currentDialogue.value?.speaker) {
case 'player':
return { bg: 'bg-blue-500/20', text: 'text-blue-400' }
case 'npc':
return { bg: 'bg-green-500/20', text: 'text-green-400' }
case 'mysterious':
return { bg: 'bg-purple-500/20', text: 'text-purple-400' }
default:
return { bg: 'bg-muted', text: 'text-muted-foreground' }
}
})
// 获取说话者名称
const getSpeakerName = (dialogue: StoryDialogue): string => {
if (dialogue.speakerNameKey) {
return t(dialogue.speakerNameKey)
}
switch (dialogue.speaker) {
case 'player':
return t('campaign.dialogue.player')
case 'npc':
return t('campaign.dialogue.npc')
case 'mysterious':
return t('campaign.dialogue.mysterious')
default:
return t('campaign.dialogue.narrator')
}
}
// 开始打字机效果
const startTypewriter = () => {
const text = t(currentDialogue.value?.textKey || '')
if (!text) return
displayedText.value = ''
isTyping.value = true
let charIndex = 0
typewriterInterval.value = setInterval(() => {
if (charIndex < text.length) {
displayedText.value += text[charIndex]
charIndex++
} else {
stopTypewriter()
}
}, 30) // 每30ms显示一个字符
}
// 停止打字机效果
const stopTypewriter = () => {
if (typewriterInterval.value) {
clearInterval(typewriterInterval.value)
typewriterInterval.value = null
}
isTyping.value = false
}
// 跳过打字机效果
const skipTyping = () => {
stopTypewriter()
displayedText.value = t(currentDialogue.value?.textKey || '')
}
// 处理继续
const handleContinue = () => {
if (isLastDialogue.value) {
emit('close')
} else {
currentIndex.value++
}
}
// 处理选项选择
const handleChoice = (choice: DialogueChoice) => {
emit('choice', choice)
// 如果有下一个对话ID跳转到对应对话
if (choice.nextDialogueId) {
const nextIndex = props.dialogues.findIndex(d => d.id === choice.nextDialogueId)
if (nextIndex !== -1) {
currentIndex.value = nextIndex
return
}
}
// 否则继续到下一个
handleContinue()
}
// 处理关闭
const handleClose = (open: boolean) => {
if (!open) {
emit('close')
}
}
// 监听对话变化,启动打字机
watch(
currentIndex,
() => {
stopTypewriter()
startTypewriter()
},
{ immediate: false }
)
// 初始化
onMounted(() => {
startTypewriter()
})
// 清理
onUnmounted(() => {
stopTypewriter()
})
</script>
<style scoped>
.stars-bg {
position: absolute;
inset: 0;
background-image: radial-gradient(circle at 10% 20%, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
radial-gradient(circle at 90% 80%, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.02) 2px, transparent 2px);
background-size: 50px 50px, 80px 80px, 120px 120px;
animation: twinkle 3s ease-in-out infinite;
}
@keyframes twinkle {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
</style>

View File

@@ -1,5 +1,9 @@
<template>
<div v-if="!isUnlocked" class="absolute inset-0 z-10 bg-background/70 backdrop-blur-[2px] rounded-lg flex items-center justify-center">
<!-- 遮罩从标题下方开始不遮挡名称 -->
<div
v-if="!isUnlocked"
class="absolute inset-x-0 top-30 sm:top-25 bottom-0 z-10 bg-background/70 backdrop-blur-[2px] rounded-b-lg flex items-center justify-center"
>
<div class="text-center p-4 space-y-2">
<div class="flex justify-center">
<div class="rounded-full bg-muted p-2">
@@ -20,8 +24,8 @@
<AlertDialogDescription>
<div class="space-y-2">
<div v-for="(req, index) in requirementsDialogItems" :key="index" class="flex items-center gap-2 text-sm">
<Check v-if="req.met" :size="16" class="text-green-500 flex-shrink-0" />
<X v-else :size="16" class="text-red-500 flex-shrink-0" />
<Check v-if="req.met" :size="16" class="text-green-500 shrink-0" />
<X v-else :size="16" class="text-red-500 shrink-0" />
<span>{{ req.name }}: Lv {{ req.requiredLevel }} ({{ t('common.current') }}: Lv {{ req.currentLevel }})</span>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<template>
<div class="space-y-4">
<!-- 建筑/科技等级范围表格 -->
<div v-if="type === 'building' || type === 'technology'" class="border rounded-lg overflow-hidden">
<div v-if="type === 'building' || type === 'technology'" class="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
@@ -166,6 +166,56 @@
</Table>
</div>
<!-- 矿脉储量信息仅采矿建筑显示 -->
<Card
v-if="isMiningBuilding && oreDepositInfo && miningResourceType"
class="border-2"
:class="oreDepositInfo.isDepleted ? 'border-destructive' : oreDepositInfo.isWarning ? 'border-yellow-500' : 'border-primary/30'"
>
<CardHeader class="pb-3">
<CardTitle class="text-sm flex items-center gap-2">
<ResourceIcon :type="miningResourceType" size="md" />
{{ t('buildings.oreDeposit') }}
<AlertTriangle
v-if="oreDepositInfo.isWarning || oreDepositInfo.isDepleted"
class="h-4 w-4"
:class="oreDepositInfo.isDepleted ? 'text-destructive' : 'text-yellow-500'"
/>
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('buildings.remainingDeposit') }}:</span>
<span class="font-medium">
<NumberWithTooltip :value="oreDepositInfo.remaining" />
/
<NumberWithTooltip :value="oreDepositInfo.initial" />
</span>
</div>
<Progress
:model-value="oreDepositInfo.percentage"
class="h-2"
:class="oreDepositInfo.isDepleted ? 'bg-destructive/20' : oreDepositInfo.isWarning ? 'bg-yellow-500/20' : ''"
/>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ oreDepositInfo.percentage.toFixed(1) }}%</span>
<span v-if="!oreDepositInfo.isDepleted">{{ t('buildings.depletionTime') }}: {{ oreDepositInfo.depletionTimeFormatted }}</span>
<span v-else class="text-destructive font-medium">{{ t('buildings.depositDepleted') }}</span>
</div>
</div>
<div
v-if="oreDepositInfo.isWarning && !oreDepositInfo.isDepleted"
class="text-xs text-yellow-600 dark:text-yellow-400 bg-yellow-500/10 p-2 rounded"
>
{{ t('buildings.depositWarning') }}
</div>
<div v-if="oreDepositInfo.isDepleted" class="text-xs text-destructive bg-destructive/10 p-2 rounded">
{{ t('buildings.depositDepletedMessage') }}
</div>
</CardContent>
</Card>
<!-- 建筑/科技累积统计 -->
<div v-if="type === 'building' || type === 'technology'" class="grid grid-cols-2 gap-4">
<Card>
@@ -393,15 +443,20 @@
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import NumberWithTooltip from '@/components/NumberWithTooltip.vue'
import NumberWithTooltip from '@/components/common/NumberWithTooltip.vue'
import { Sword, Shield, ShieldCheck, Zap, Package, Fuel } from 'lucide-vue-next'
import * as buildingLogic from '@/logic/buildingLogic'
import * as researchLogic from '@/logic/researchLogic'
import * as pointsLogic from '@/logic/pointsLogic'
import * as officerLogic from '@/logic/officerLogic'
import * as shipLogic from '@/logic/shipLogic'
import * as oreDepositLogic from '@/logic/oreDepositLogic'
import * as resourceLogic from '@/logic/resourceLogic'
import { SHIPS, DEFENSES } from '@/config/gameConfig'
import { formatTime } from '@/utils/format'
import { Progress } from '@/components/ui/progress'
import ResourceIcon from '@/components/common/ResourceIcon.vue'
import { AlertTriangle } from 'lucide-vue-next'
const { t } = useI18n()
const gameStore = useGameStore()
@@ -442,6 +497,11 @@
return currentPlanet.value.buildings['researchLab'] || 0
})
//
const energyTechLevel = computed(() => {
return gameStore.player.technologies['energyTechnology'] || 0
})
//
const typeKey = computed(() => {
const typeMap = {
@@ -472,11 +532,79 @@
})
const showConsumptionColumn = computed(() => {
if (props.type !== 'building') return false
const buildingType = props.itemType as BuildingType
//
return [
'metalMine',
'crystalMine',
'deuteriumSynthesizer',
'roboticsFactory',
'naniteFactory',
'shipyard',
'researchLab',
'missileSilo',
'terraformer',
'darkMatterCollector',
'sensorPhalanx',
'jumpGate'
].includes(buildingType)
})
//
const isMiningBuilding = computed(() => {
if (props.type !== 'building') return false
const buildingType = props.itemType as BuildingType
return ['metalMine', 'crystalMine', 'deuteriumSynthesizer'].includes(buildingType)
})
//
const miningResourceType = computed((): 'metal' | 'crystal' | 'deuterium' | null => {
if (!isMiningBuilding.value) return null
const buildingType = props.itemType as BuildingType
if (buildingType === 'metalMine') return 'metal'
if (buildingType === 'crystalMine') return 'crystal'
if (buildingType === 'deuteriumSynthesizer') return 'deuterium'
return null
})
//
const oreDepositInfo = computed(() => {
if (!currentPlanet.value || !miningResourceType.value || !currentPlanet.value.oreDeposits) {
return null
}
const deposits = currentPlanet.value.oreDeposits
const resourceType = miningResourceType.value
const remaining = deposits[resourceType]
const initial =
resourceType === 'metal' ? deposits.initialMetal : resourceType === 'crystal' ? deposits.initialCrystal : deposits.initialDeuterium
const percentage = oreDepositLogic.getDepositPercentage(deposits, resourceType)
const isWarning = oreDepositLogic.isDepositWarning(deposits, resourceType)
const isDepleted = oreDepositLogic.isDepositDepleted(deposits, resourceType)
//
const production = resourceLogic.calculateResourceProduction(currentPlanet.value, {
resourceProductionBonus: activeBonuses.value.resourceProductionBonus,
darkMatterProductionBonus: activeBonuses.value.darkMatterProductionBonus,
energyProductionBonus: activeBonuses.value.energyProductionBonus
})
const productionPerHour = production[resourceType]
//
const depletionTimeHours = oreDepositLogic.calculateDepletionTime(deposits, resourceType, productionPerHour)
const depletionTimeFormatted = oreDepositLogic.formatDepletionTime(depletionTimeHours)
return {
remaining,
initial,
percentage,
isWarning,
isDepleted,
productionPerHour,
depletionTimeFormatted
}
})
const showCapacityColumn = computed(() => {
if (props.type !== 'building') return false
const buildingType = props.itemType as BuildingType
@@ -717,7 +845,8 @@
}),
darkMatterCollector: lvl => ({
capacity: 1000 + lvl * 100,
production: Math.floor(25 * lvl * Math.pow(1.5, lvl))
production: Math.floor(25 * lvl * Math.pow(1.5, lvl)),
consumption: Math.floor(10 * lvl * Math.pow(1.1, lvl))
}),
darkMatterTank: lvl => ({
capacity: Math.floor(1000 * Math.pow(2, lvl) * storageBonus)
@@ -726,25 +855,39 @@
production: Math.floor(150 * lvl * Math.pow(1.15, lvl))
}),
shipyard: lvl => ({
fleetStorage: 1000 * lvl
fleetStorage: 1000 * lvl,
consumption: Math.floor(8 * lvl * Math.pow(1.1, lvl))
}),
hangar: lvl => ({
fleetStorage: 500 * lvl
}),
terraformer: () => ({
spaceBonus: 30
}),
lunarBase: () => ({
spaceBonus: 30
}),
roboticsFactory: lvl => ({
buildSpeedBonus: lvl
buildSpeedBonus: lvl,
consumption: Math.floor(5 * lvl * Math.pow(1.1, lvl))
}),
naniteFactory: lvl => ({
buildSpeedBonus: lvl * 2
buildSpeedBonus: lvl * 2,
consumption: Math.floor(20 * lvl * Math.pow(1.15, lvl))
}),
researchLab: lvl => ({
researchSpeedBonus: lvl
researchSpeedBonus: lvl,
consumption: Math.floor(12 * lvl * Math.pow(1.1, lvl))
}),
missileSilo: lvl => ({
consumption: Math.floor(8 * lvl * Math.pow(1.1, lvl))
}),
terraformer: lvl => ({
spaceBonus: 30,
consumption: Math.floor(25 * lvl * Math.pow(1.15, lvl))
}),
sensorPhalanx: lvl => ({
consumption: Math.floor(15 * lvl * Math.pow(1.12, lvl))
}),
jumpGate: lvl => ({
consumption: Math.floor(50 * lvl * Math.pow(1.2, lvl))
})
}
@@ -772,7 +915,8 @@
techType,
level - 1,
activeBonuses.value.researchSpeedBonus,
researchLabLevel.value
researchLabLevel.value,
energyTechLevel.value
)
let researchSpeedBonus = 0

View File

@@ -3,23 +3,17 @@
<PopoverTrigger as-child>
<span class="cursor-pointer underline decoration-dotted underline-offset-4 touch-manipulation">{{ formatNumber(value, 1) }}</span>
</PopoverTrigger>
<PopoverContent class="w-auto p-2" side="top" align="center">
<p class="font-mono text-sm">{{ formattedValue }}</p>
<PopoverContent class="w-auto p-2 z-100" side="top" align="center">
<p class="font-mono text-sm">{{ props.value.toLocaleString() }}</p>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { formatNumber } from '@/utils/format'
const props = defineProps<{
value: number
}>()
//
const formattedValue = computed(() => {
return props.value.toLocaleString()
})
</script>

View File

@@ -0,0 +1,628 @@
<template>
<div class="battle-animation-container">
<!-- 播放控制栏 -->
<div class="flex items-center justify-between gap-2 mb-4 p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2">
<Button variant="outline" size="icon" @click="restart" :disabled="!canRestart">
<RotateCcw class="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" @click="previousRound" :disabled="!canGoPrevious">
<SkipBack class="h-4 w-4" />
</Button>
<Button :variant="isPlaying ? 'default' : 'outline'" size="icon" @click="togglePlay" :disabled="!canPlay">
<Pause v-if="isPlaying" class="h-4 w-4" />
<Play v-else class="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" @click="nextRound" :disabled="!canGoNext">
<SkipForward class="h-4 w-4" />
</Button>
</div>
<!-- 播放速度 -->
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">{{ t('messagesView.speed') }}:</span>
<Select v-model="speedMultiplier">
<SelectTrigger class="w-20 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent class="z-100">
<SelectItem value="0.5">0.5x</SelectItem>
<SelectItem value="1">1x</SelectItem>
<SelectItem value="2">2x</SelectItem>
<SelectItem value="4">4x</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<!-- 战斗场景 -->
<div class="battle-scene relative overflow-hidden rounded-lg border bg-gradient-to-b from-slate-900 to-slate-950 p-4 min-h-[300px]">
<!-- 星空背景 -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<div v-for="i in 20" :key="i" class="star" :style="getStarStyle(i)" />
</div>
<!-- 攻击方区域 -->
<div class="relative z-10 flex justify-between items-center gap-4">
<!-- 攻击方舰队 -->
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Sword class="h-4 w-4 text-red-400" />
<span class="text-sm font-medium text-red-400">{{ t('simulatorView.attacker') }}</span>
</div>
<div class="fleet-display attacker" :class="{ attacking: attackAnimationPhase === 'attack' }">
<div class="grid grid-cols-5 gap-1">
<div
v-for="(count, shipType) in currentAttackerFleet"
:key="shipType"
class="ship-unit flex flex-col items-center p-1.5 rounded bg-red-950/50 border border-red-900/50"
:class="{ 'exploding': isShipExploding('attacker', shipType as ShipType) }"
>
<Rocket class="h-4 w-4 text-red-400" />
<span class="text-[10px] text-red-300">{{ formatNumber(count, 0) }}</span>
</div>
</div>
<div class="mt-2 text-xs text-red-400/80">{{ t('messagesView.power') }}: {{ formatNumber(currentAttackerPower) }}</div>
</div>
</div>
<!-- VS 标志 -->
<div class="flex flex-col items-center gap-2">
<div
class="vs-badge w-12 h-12 rounded-full bg-yellow-500/20 border-2 border-yellow-500/50 flex items-center justify-center"
:class="{ 'pulse-animation': attackAnimationPhase === 'attack' }"
>
<Swords class="h-6 w-6 text-yellow-400" />
</div>
<!-- 当前回合损失动画 -->
<Transition name="damage-popup">
<div v-if="showDamageNumbers" class="damage-numbers text-center">
<div v-if="displayedLosses.attacker > 0" class="text-red-400 text-xs font-bold animate-bounce">
-{{ displayedLosses.attacker }}
</div>
<div v-if="displayedLosses.defender > 0" class="text-blue-400 text-xs font-bold animate-bounce">
-{{ displayedLosses.defender }}
</div>
</div>
</Transition>
</div>
<!-- 防守方舰队 -->
<div class="flex-1">
<div class="flex items-center justify-end gap-2 mb-2">
<span class="text-sm font-medium text-blue-400">{{ t('simulatorView.defender') }}</span>
<ShieldIcon class="h-4 w-4 text-blue-400" />
</div>
<div class="fleet-display defender" :class="{ defending: attackAnimationPhase === 'attack' }">
<div class="grid grid-cols-5 gap-1 justify-end">
<div
v-for="(count, shipType) in currentDefenderFleet"
:key="shipType"
class="ship-unit flex flex-col items-center p-1.5 rounded bg-blue-950/50 border border-blue-900/50"
:class="{ 'exploding': isShipExploding('defender', shipType as string) }"
>
<component :is="isDefenseType(String(shipType)) ? Shield : Rocket" class="h-4 w-4 text-blue-400" />
<span class="text-[10px] text-blue-300">{{ formatNumber(count, 0) }}</span>
</div>
</div>
<div class="mt-2 text-xs text-blue-400/80 text-right">
{{ t('messagesView.power') }}: {{ formatNumber(currentDefenderPower) }}
</div>
</div>
</div>
</div>
<!-- 战斗日志 -->
<div class="battle-log mt-4 p-3 bg-black/30 rounded border border-white/10 max-h-32 overflow-y-auto">
<div v-for="(log, index) in battleLogs" :key="index" class="text-xs mb-1" :class="log.type">
<span class="text-muted-foreground">[{{ log.round }}]</span>
{{ log.message }}
</div>
<div v-if="battleLogs.length === 0" class="text-xs text-muted-foreground text-center py-2">
{{ t('messagesView.battleLogEmpty') }}
</div>
</div>
</div>
<!-- 战斗结果预览 (仅在完成时显示) -->
<Transition name="fade">
<div v-if="showResult" class="mt-4 p-4 rounded-lg border text-center" :class="resultStyle">
<p class="text-lg font-bold">{{ resultText }}</p>
<p class="text-sm text-muted-foreground mt-1">
{{ t('simulatorView.afterRounds').replace('{rounds}', String(totalRounds)) }}
</p>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { formatNumber } from '@/utils/format'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Play, Pause, SkipBack, SkipForward, RotateCcw, Sword, Shield as ShieldIcon, Swords, Rocket, Shield } from 'lucide-vue-next'
import type { BattleResult, ShipType, DefenseType, Fleet } from '@/types/game'
const props = defineProps<{
report: BattleResult
}>()
const emit = defineEmits<{
(e: 'complete'): void
}>()
const { t } = useI18n()
const { SHIPS, DEFENSES } = useGameConfig()
// 播放状态
const isPlaying = ref(false)
const currentRoundIndex = ref(0)
const speedMultiplier = ref('1')
const attackAnimationPhase = ref<'idle' | 'attack' | 'damage'>('idle')
const showDamageNumbers = ref(false)
const showResult = ref(false)
// 爆炸动画追踪
const explodingShips = ref<{ side: 'attacker' | 'defender'; type: string }[]>([])
// 当前显示的损失数字(用于动画显示)
const displayedLosses = ref({ attacker: 0, defender: 0 })
// 战斗日志
interface BattleLog {
round: number
message: string
type: 'attacker-loss' | 'defender-loss' | 'info'
}
const battleLogs = ref<BattleLog[]>([])
// 计算属性
const totalRounds = computed(() => props.report.roundDetails?.length || props.report.rounds || 1)
const canPlay = computed(() => currentRoundIndex.value < totalRounds.value)
const canGoPrevious = computed(() => currentRoundIndex.value > 0)
const canGoNext = computed(() => currentRoundIndex.value < totalRounds.value)
const canRestart = computed(() => currentRoundIndex.value > 0 || battleLogs.value.length > 0)
// 当前回合的舰队状态(通过累计损失计算)
const currentAttackerFleet = computed(() => {
const fleet: Partial<Fleet> = { ...props.report.attackerFleet }
if (props.report.roundDetails) {
for (let i = 0; i < currentRoundIndex.value; i++) {
const roundLosses = props.report.roundDetails[i]?.attackerLosses || {}
for (const [shipType, count] of Object.entries(roundLosses)) {
if (fleet[shipType as keyof Fleet] !== undefined) {
fleet[shipType as keyof Fleet] = Math.max(0, (fleet[shipType as keyof Fleet] || 0) - count)
}
}
}
}
// 过滤掉数量为0的
return Object.fromEntries(Object.entries(fleet).filter(([, count]) => count > 0))
})
const currentDefenderFleet = computed(() => {
const fleet: Partial<Fleet> = { ...props.report.defenderFleet }
const defense: Partial<Record<DefenseType, number>> = { ...props.report.defenderDefense }
if (props.report.roundDetails) {
for (let i = 0; i < currentRoundIndex.value; i++) {
const roundLosses = props.report.roundDetails[i]?.defenderLosses || { fleet: {}, defense: {} }
for (const [shipType, count] of Object.entries(roundLosses.fleet || {})) {
if (fleet[shipType as keyof Fleet] !== undefined) {
fleet[shipType as keyof Fleet] = Math.max(0, (fleet[shipType as keyof Fleet] || 0) - count)
}
}
for (const [defType, count] of Object.entries(roundLosses.defense || {})) {
if (defense[defType as DefenseType] !== undefined) {
defense[defType as DefenseType] = Math.max(0, (defense[defType as DefenseType] || 0) - count)
}
}
}
}
// 合并舰队和防御
const combined = {
...Object.fromEntries(Object.entries(fleet).filter(([, count]) => count > 0)),
...Object.fromEntries(Object.entries(defense).filter(([, count]) => count > 0))
}
return combined
})
const currentAttackerPower = computed(() => {
if (props.report.roundDetails && currentRoundIndex.value > 0) {
return props.report.roundDetails[currentRoundIndex.value - 1]?.attackerRemainingPower || 0
}
// 初始战斗力
return calculateFleetPower(props.report.attackerFleet)
})
const currentDefenderPower = computed(() => {
if (props.report.roundDetails && currentRoundIndex.value > 0) {
return props.report.roundDetails[currentRoundIndex.value - 1]?.defenderRemainingPower || 0
}
// 初始战斗力
return calculateFleetPower(props.report.defenderFleet) + calculateDefensePower(props.report.defenderDefense)
})
const resultStyle = computed(() => {
if (props.report.winner === 'draw') {
return 'bg-gray-50 dark:bg-gray-950/30 border-gray-200 dark:border-gray-800 text-gray-700 dark:text-gray-300'
}
if (props.report.winner === 'attacker') {
return 'bg-red-50 dark:bg-red-950/30 border-red-300 dark:border-red-800 text-red-700 dark:text-red-300'
}
return 'bg-blue-50 dark:bg-blue-950/30 border-blue-300 dark:border-blue-800 text-blue-700 dark:text-blue-300'
})
const resultText = computed(() => {
if (props.report.winner === 'draw') return t('messagesView.draw')
if (props.report.winner === 'attacker') return t('messagesView.attackerWins')
return t('messagesView.defenderWins')
})
// 辅助函数
const calculateFleetPower = (fleet: Partial<Fleet>): number => {
let power = 0
for (const [shipType, count] of Object.entries(fleet)) {
const config = SHIPS.value[shipType as ShipType]
if (config) {
power += (config.attack + config.shield + config.armor) * count
}
}
return power
}
const calculateDefensePower = (defense: Partial<Record<DefenseType, number>>): number => {
let power = 0
for (const [defType, count] of Object.entries(defense)) {
const config = DEFENSES.value[defType as DefenseType]
if (config) {
power += (config.attack + config.shield + config.armor) * count
}
}
return power
}
const isDefenseType = (type: string): boolean => {
return type in DEFENSES.value
}
const isShipExploding = (side: 'attacker' | 'defender', type: string): boolean => {
return explodingShips.value.some(s => s.side === side && s.type === type)
}
const getStarStyle = (index: number): Record<string, string> => {
const seed = index * 1234
const x = seed % 100
const y = (seed * 7) % 100
const size = 1 + (seed % 2)
const opacity = 0.3 + (seed % 5) / 10
const delay = seed % 3000
return {
position: 'absolute',
left: `${x}%`,
top: `${y}%`,
width: `${size}px`,
height: `${size}px`,
backgroundColor: 'white',
borderRadius: '50%',
opacity: String(opacity),
animation: `twinkle 2s ease-in-out ${delay}ms infinite`
}
}
// 播放控制
let playTimeoutId: ReturnType<typeof setTimeout> | null = null
let isPlayingRound = false // 防止重复执行
const togglePlay = () => {
if (isPlaying.value) {
pause()
} else {
play()
}
}
const play = () => {
if (currentRoundIndex.value >= totalRounds.value) {
restart()
}
isPlaying.value = true
scheduleNextRound()
}
const pause = () => {
isPlaying.value = false
if (playTimeoutId) {
clearTimeout(playTimeoutId)
playTimeoutId = null
}
}
const scheduleNextRound = () => {
if (!isPlaying.value) return
if (playTimeoutId) clearTimeout(playTimeoutId)
// 使用 setTimeout 而非 setInterval确保每回合顺序执行
playTimeoutId = setTimeout(async () => {
if (!isPlaying.value) return
if (currentRoundIndex.value < totalRounds.value) {
await playRound()
scheduleNextRound() // 回合完成后再调度下一回合
} else {
pause()
showResult.value = true
emit('complete')
}
}, 100) // 短暂延迟启动
}
const playRound = async () => {
if (isPlayingRound) return // 防止重复执行
if (currentRoundIndex.value >= totalRounds.value) return
isPlayingRound = true
const speed = parseFloat(speedMultiplier.value)
const roundIndex = currentRoundIndex.value
const roundData = props.report.roundDetails?.[roundIndex]
// 攻击动画阶段
attackAnimationPhase.value = 'attack'
// 添加日志
battleLogs.value.push({
round: roundIndex + 1,
message: t('messagesView.roundStarted').replace('{round}', String(roundIndex + 1)),
type: 'info'
})
// 等待攻击动画
await sleep(400 / speed)
// 伤害阶段
attackAnimationPhase.value = 'damage'
// 计算当前回合的损失数字
if (roundData) {
const attackerLoss = Object.values(roundData.attackerLosses).reduce((sum, count) => sum + count, 0)
const defenderLoss =
Object.values(roundData.defenderLosses.fleet || {}).reduce((sum, count) => sum + count, 0) +
Object.values(roundData.defenderLosses.defense || {}).reduce((sum, count) => sum + count, 0)
displayedLosses.value = { attacker: attackerLoss, defender: defenderLoss }
} else {
displayedLosses.value = { attacker: 0, defender: 0 }
}
showDamageNumbers.value = true
if (roundData) {
// 记录攻击方损失
for (const [shipType, count] of Object.entries(roundData.attackerLosses)) {
if (count > 0) {
explodingShips.value.push({ side: 'attacker', type: shipType })
battleLogs.value.push({
round: roundIndex + 1,
message: t('messagesView.shipDestroyed')
.replace('{count}', String(count))
.replace('{ship}', SHIPS.value[shipType as ShipType]?.name || shipType),
type: 'attacker-loss'
})
}
}
// 记录防守方损失
for (const [shipType, count] of Object.entries(roundData.defenderLosses.fleet || {})) {
if (count > 0) {
explodingShips.value.push({ side: 'defender', type: shipType })
battleLogs.value.push({
round: roundIndex + 1,
message: t('messagesView.shipDestroyed')
.replace('{count}', String(count))
.replace('{ship}', SHIPS.value[shipType as ShipType]?.name || shipType),
type: 'defender-loss'
})
}
}
for (const [defType, count] of Object.entries(roundData.defenderLosses.defense || {})) {
if (count > 0) {
explodingShips.value.push({ side: 'defender', type: defType })
battleLogs.value.push({
round: roundIndex + 1,
message: t('messagesView.defenseDestroyed')
.replace('{count}', String(count))
.replace('{defense}', DEFENSES.value[defType as DefenseType]?.name || defType),
type: 'defender-loss'
})
}
}
}
// 等待伤害显示
await sleep(600 / speed)
// 清理状态
attackAnimationPhase.value = 'idle'
showDamageNumbers.value = false
explodingShips.value = []
currentRoundIndex.value++
isPlayingRound = false
}
const nextRound = () => {
if (currentRoundIndex.value < totalRounds.value) {
pause()
playRound()
}
}
const previousRound = () => {
if (currentRoundIndex.value > 0) {
pause()
currentRoundIndex.value--
// 移除该回合的日志
battleLogs.value = battleLogs.value.filter(log => log.round <= currentRoundIndex.value)
showResult.value = false
}
}
const restart = () => {
pause()
currentRoundIndex.value = 0
battleLogs.value = []
showResult.value = false
explodingShips.value = []
attackAnimationPhase.value = 'idle'
showDamageNumbers.value = false
displayedLosses.value = { attacker: 0, defender: 0 }
}
const sleep = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms))
}
// 监听速度变化
watch(speedMultiplier, () => {
if (isPlaying.value) {
scheduleNextRound()
}
})
// 清理
onUnmounted(() => {
if (playTimeoutId) {
clearTimeout(playTimeoutId)
}
})
// 暴露给父组件
defineExpose({
currentRoundIndex,
totalRounds
})
</script>
<style scoped>
.star {
will-change: opacity;
}
@keyframes twinkle {
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 0.8;
}
}
.fleet-display {
will-change: transform;
}
.fleet-display.attacking {
animation: shake 0.3s ease-in-out;
}
.fleet-display.defending {
animation: shake 0.3s ease-in-out 0.1s;
}
@keyframes shake {
0%,
100% {
transform: translate3d(0, 0, 0);
}
25% {
transform: translate3d(-3px, 0, 0);
}
75% {
transform: translate3d(3px, 0, 0);
}
}
.ship-unit {
will-change: transform, opacity;
}
.ship-unit.exploding {
animation: explode 0.5s ease-out forwards;
}
@keyframes explode {
0% {
transform: scale3d(1, 1, 1);
opacity: 1;
}
50% {
transform: scale3d(1.3, 1.3, 1);
opacity: 0.5;
background-color: rgba(239, 68, 68, 0.5);
}
100% {
transform: scale3d(0.8, 0.8, 1);
opacity: 0.3;
}
}
.vs-badge {
will-change: transform;
}
.pulse-animation {
animation: pulse 0.5s ease-in-out;
}
@keyframes pulse {
0%,
100% {
transform: scale3d(1, 1, 1);
}
50% {
transform: scale3d(1.2, 1.2, 1);
}
}
.damage-popup-enter-active,
.damage-popup-leave-active {
transition: all 0.3s ease;
}
.damage-popup-enter-from {
opacity: 0;
transform: translate3d(0, 10px, 0);
}
.damage-popup-leave-to {
opacity: 0;
transform: translate3d(0, -10px, 0);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.battle-log .attacker-loss {
color: rgb(248, 113, 113);
}
.battle-log .defender-loss {
color: rgb(96, 165, 250);
}
.battle-log .info {
color: rgb(156, 163, 175);
}
</style>

View File

@@ -0,0 +1,526 @@
<template>
<Dialog v-model:open="isOpen">
<ScrollableDialogContent container-class="sm:max-w-4xl max-h-[90vh]">
<template #header>
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Sword class="h-5 w-5" />
{{ t('messagesView.battleReport') }}
</DialogTitle>
<DialogDescription v-if="report">
{{ formatDate(report.timestamp) }}
</DialogDescription>
</DialogHeader>
</template>
<div v-if="report" class="space-y-4">
<!-- 战斗动画切换 -->
<div v-if="report.roundDetails && report.roundDetails.length > 0" class="flex items-center justify-between gap-2">
<!-- 左侧: 回合进度 (仅在动画模式下显示) -->
<div v-if="showAnimation && animationPlayerRef" class="flex items-center gap-1.5 text-sm text-muted-foreground">
<span class="font-medium text-foreground">{{ animationPlayerRef.currentRoundIndex }}</span>
<span>/</span>
<span>{{ animationPlayerRef.totalRounds }}</span>
<span class="text-xs">{{ t('messagesView.roundsPlayed') }}</span>
</div>
<div v-else />
<!-- 右侧: 切换按钮 -->
<Button variant="outline" size="sm" @click="showAnimation = !showAnimation" class="gap-2">
<component :is="showAnimation ? FileText : Clapperboard" class="h-4 w-4" />
{{ showAnimation ? t('messagesView.showDetails') : t('messagesView.playAnimation') }}
</Button>
</div>
<!-- 战斗动画播放器 -->
<BattleAnimationPlayer
v-if="showAnimation && report.roundDetails && report.roundDetails.length > 0"
ref="animationPlayerRef"
:report="report"
@complete="onAnimationComplete"
/>
<!-- 详细信息动画播放时隐藏 -->
<template v-if="!showAnimation">
<!-- 战斗双方信息 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 攻击方星球 -->
<div class="p-4 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-3">
<div class="p-2 bg-background rounded-full border">
<Sword class="h-5 w-5" />
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold">{{ t('simulatorView.attacker') }}</p>
<p v-if="attackerPlanet" class="text-sm text-muted-foreground truncate">
{{ attackerPlanet.name }} [{{ attackerPlanet.position.galaxy }}:{{ attackerPlanet.position.system }}:{{
attackerPlanet.position.position
}}]
</p>
<p v-else class="text-sm text-muted-foreground">{{ report.attackerPlanetId }}</p>
</div>
</div>
</div>
<!-- 防守方星球 -->
<div class="p-4 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-3">
<div class="p-2 bg-background rounded-full border">
<ShieldIcon class="h-5 w-5" />
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold">{{ t('simulatorView.defender') }}</p>
<p v-if="defenderPlanet" class="text-sm text-muted-foreground truncate">
{{ defenderPlanet.name }} [{{ defenderPlanet.position.galaxy }}:{{ defenderPlanet.position.system }}:{{
defenderPlanet.position.position
}}]
</p>
<p v-else class="text-sm text-muted-foreground">{{ report.defenderPlanetId }}</p>
</div>
</div>
</div>
</div>
<!-- 胜利者 -->
<div class="text-center p-5 rounded-lg border" :class="getPlayerResultStyle()">
<p class="text-xl font-bold">
{{
report.winner === 'draw' ? t('messagesView.draw') : isPlayerVictory ? t('messagesView.victory') : t('messagesView.defeat')
}}
</p>
<p v-if="report.rounds" class="text-sm text-muted-foreground mt-1">
{{ t('simulatorView.afterRounds').replace('{rounds}', String(report.rounds)) }}
</p>
</div>
<!-- 损失对比 -->
<div class="space-y-3">
<h4 class="font-semibold text-sm">{{ t('messagesView.losses') }}</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 攻击方损失 -->
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium">{{ t('simulatorView.attacker') }}</span>
<Badge variant="secondary" class="text-xs">{{ getTotalLossCount(report.attackerLosses) }}</Badge>
</div>
<div class="space-y-1.5">
<div v-for="(count, shipType) in report.attackerLosses" :key="shipType" class="flex items-center justify-between text-xs">
<span class="text-muted-foreground truncate">{{ SHIPS[shipType].name }}</span>
<span class="font-medium text-destructive">-{{ count }}</span>
</div>
<p v-if="Object.keys(report.attackerLosses).length === 0" class="text-xs text-muted-foreground text-center py-2">
{{ t('messagesView.noLosses') }}
</p>
</div>
</div>
<!-- 防守方损失 -->
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium">{{ t('simulatorView.defender') }}</span>
<Badge variant="secondary" class="text-xs">{{ getTotalDefenderLossCount(report.defenderLosses) }}</Badge>
</div>
<div class="space-y-1.5">
<div
v-for="(count, shipType) in report.defenderLosses.fleet"
:key="shipType"
class="flex items-center justify-between text-xs"
>
<span class="text-muted-foreground truncate">{{ SHIPS[shipType].name }}</span>
<span class="font-medium text-destructive">-{{ count }}</span>
</div>
<div
v-for="(count, defenseType) in report.defenderLosses.defense"
:key="defenseType"
class="flex items-center justify-between text-xs"
>
<span class="text-muted-foreground truncate">{{ DEFENSES[defenseType].name }}</span>
<span class="font-medium text-destructive">-{{ count }}</span>
</div>
<p
v-if="Object.keys(report.defenderLosses.fleet).length === 0 && Object.keys(report.defenderLosses.defense).length === 0"
class="text-xs text-muted-foreground text-center py-2"
>
{{ t('messagesView.noLosses') }}
</p>
</div>
</div>
</div>
</div>
<!-- 剩余单位 -->
<div v-if="hasAnyRemaining" class="space-y-3">
<h4 class="font-semibold text-sm">{{ t('messagesView.remainingUnits') }}</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 攻击方剩余 -->
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium">{{ t('simulatorView.attacker') }}</span>
<Badge v-if="report.attackerRemaining" variant="outline" class="text-xs">
{{ getTotalLossCount(report.attackerRemaining) }}
</Badge>
</div>
<div class="space-y-1.5">
<template v-if="report.attackerRemaining && Object.keys(report.attackerRemaining).length > 0">
<div
v-for="(count, shipType) in report.attackerRemaining"
:key="shipType"
class="flex items-center justify-between p-1.5 bg-white/50 dark:bg-black/20 rounded text-xs"
>
<span class="text-muted-foreground truncate">{{ SHIPS[shipType].name }}</span>
<span class="font-bold">{{ count }}</span>
</div>
</template>
<p v-else class="text-xs text-muted-foreground text-center py-2">{{ t('messagesView.allDestroyed') }}</p>
</div>
</div>
<!-- 防守方剩余 -->
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium">{{ t('simulatorView.defender') }}</span>
<Badge v-if="report.defenderRemaining" variant="outline" class="text-xs">
{{ getTotalDefenderRemainingCount(report.defenderRemaining) }}
</Badge>
</div>
<div class="space-y-1.5">
<template
v-if="
report.defenderRemaining &&
(Object.keys(report.defenderRemaining.fleet || {}).length > 0 ||
Object.keys(report.defenderRemaining.defense || {}).length > 0)
"
>
<div
v-for="(count, shipType) in report.defenderRemaining.fleet"
:key="shipType"
class="flex items-center justify-between p-1.5 bg-white/50 dark:bg-black/20 rounded text-xs"
>
<span class="text-muted-foreground truncate">{{ SHIPS[shipType].name }}</span>
<span class="font-bold">{{ count }}</span>
</div>
<div
v-for="(count, defenseType) in report.defenderRemaining.defense"
:key="defenseType"
class="flex items-center justify-between p-1.5 bg-white/50 dark:bg-black/20 rounded text-xs"
>
<span class="text-muted-foreground truncate">{{ DEFENSES[defenseType].name }}</span>
<span class="font-bold">{{ count }}</span>
</div>
</template>
<p v-else class="text-xs text-muted-foreground text-center py-2">{{ t('messagesView.allDestroyed') }}</p>
</div>
</div>
</div>
</div>
<!-- 掠夺资源 -->
<div
v-if="report.plunder && (report.plunder.metal > 0 || report.plunder.crystal > 0 || report.plunder.deuterium > 0)"
class="space-y-3"
>
<div class="flex items-center gap-2">
<Package class="h-4 w-4" />
<h4 class="font-semibold text-sm">{{ t('messagesView.plunder') }}</h4>
</div>
<div class="grid grid-cols-3 gap-3">
<div v-if="report.plunder.metal > 0" class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-1">
<ResourceIcon type="metal" size="sm" />
<span class="text-xs text-muted-foreground">{{ t('resources.metal') }}</span>
</div>
<p class="text-lg font-bold">+{{ formatNumber(report.plunder.metal) }}</p>
</div>
<div v-if="report.plunder.crystal > 0" class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-1">
<ResourceIcon type="crystal" size="sm" />
<span class="text-xs text-muted-foreground">{{ t('resources.crystal') }}</span>
</div>
<p class="text-lg font-bold">+{{ formatNumber(report.plunder.crystal) }}</p>
</div>
<div v-if="report.plunder.deuterium > 0" class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-1">
<ResourceIcon type="deuterium" size="sm" />
<span class="text-xs text-muted-foreground">{{ t('resources.deuterium') }}</span>
</div>
<p class="text-lg font-bold">+{{ formatNumber(report.plunder.deuterium) }}</p>
</div>
</div>
</div>
<!-- 残骸场 -->
<div v-if="report.debrisField && (report.debrisField.metal > 0 || report.debrisField.crystal > 0)" class="space-y-3">
<div class="flex items-center gap-2">
<Recycle class="h-4 w-4" />
<h4 class="font-semibold text-sm">{{ t('messagesView.debrisField') }}</h4>
</div>
<div class="p-4 bg-muted/50 rounded-lg border">
<div class="grid grid-cols-2 gap-4">
<div v-if="report.debrisField.metal > 0" class="flex items-center gap-3">
<div class="p-2 bg-background rounded-full border">
<ResourceIcon type="metal" size="sm" />
</div>
<div>
<p class="text-xs text-muted-foreground">{{ t('resources.metal') }}</p>
<p class="font-bold">{{ formatNumber(report.debrisField.metal) }}</p>
</div>
</div>
<div v-if="report.debrisField.crystal > 0" class="flex items-center gap-3">
<div class="p-2 bg-background rounded-full border">
<ResourceIcon type="crystal" size="sm" />
</div>
<div>
<p class="text-xs text-muted-foreground">{{ t('resources.crystal') }}</p>
<p class="font-bold">{{ formatNumber(report.debrisField.crystal) }}</p>
</div>
</div>
</div>
<!-- 月球生成概率 -->
<div v-if="report.moonChance && report.moonChance > 0" class="mt-3 pt-3 border-t">
<div class="flex items-center justify-center gap-2">
<Moon class="h-4 w-4" />
<span class="text-sm">{{ t('messagesView.moonChance') }}:</span>
<span class="font-bold">{{ (report.moonChance * 100).toFixed(1) }}%</span>
</div>
</div>
</div>
</div>
<!-- 回合详情 -->
<div v-if="report.roundDetails && report.roundDetails.length > 0" class="space-y-3">
<Button @click="showRoundDetails = !showRoundDetails" variant="outline" size="sm" class="w-full gap-2">
<ListOrdered class="h-4 w-4" />
{{ showRoundDetails ? t('messagesView.hideRoundDetails') : t('messagesView.showRoundDetails') }}
<ChevronDown class="h-4 w-4 transition-transform" :class="{ 'rotate-180': showRoundDetails }" />
</Button>
<div v-if="showRoundDetails" class="relative pl-6 space-y-4">
<!-- 时间线 -->
<div class="absolute left-2 top-0 bottom-0 w-0.5 bg-border" />
<div v-for="detail in report.roundDetails" :key="detail.round" class="relative">
<!-- 时间线节点 -->
<div class="absolute -left-6 top-3 w-4 h-4 rounded-full bg-primary border-2 border-background shadow-md" />
<!-- 回合内容卡片 -->
<div class="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<Badge variant="outline">{{ t('messagesView.round').replace('{round}', String(detail.round)) }}</Badge>
</div>
<TooltipProvider :delay-duration="300">
<div class="flex gap-4 text-xs">
<Tooltip>
<TooltipTrigger as-child>
<span class="flex items-center gap-1.5">
<Sword class="h-3.5 w-3.5" />
<span class="font-medium">{{ formatNumber(detail.attackerRemainingPower) }}</span>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('messagesView.attackerRemainingPower') }}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<span class="flex items-center gap-1.5">
<ShieldIcon class="h-3.5 w-3.5" />
<span class="font-medium">{{ formatNumber(detail.defenderRemainingPower) }}</span>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('messagesView.defenderRemainingPower') }}</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<!-- 攻击方本回合损失 -->
<div class="bg-muted/50 rounded-lg p-3 border">
<p class="text-xs font-medium mb-2">{{ t('messagesView.attackerLosses') }}</p>
<div class="space-y-1">
<div
v-for="(count, shipType) in detail.attackerLosses"
:key="shipType"
class="flex justify-between text-xs p-1 bg-white/50 dark:bg-black/20 rounded"
>
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}</span>
<span class="font-bold text-red-600 dark:text-red-400">-{{ count }}</span>
</div>
<p v-if="Object.keys(detail.attackerLosses).length === 0" class="text-xs text-muted-foreground text-center py-1">
{{ t('messagesView.noLosses') }}
</p>
</div>
</div>
<!-- 防守方本回合损失 -->
<div class="bg-muted/50 rounded-lg p-3 border">
<p class="text-xs font-medium mb-2">{{ t('messagesView.defenderLosses') }}</p>
<div class="space-y-1">
<div
v-for="(count, shipType) in detail.defenderLosses.fleet"
:key="shipType"
class="flex justify-between text-xs p-1 bg-white/50 dark:bg-black/20 rounded"
>
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}</span>
<span class="font-bold text-red-600 dark:text-red-400">-{{ count }}</span>
</div>
<div
v-for="(count, defenseType) in detail.defenderLosses.defense"
:key="defenseType"
class="flex justify-between text-xs p-1 bg-white/50 dark:bg-black/20 rounded"
>
<span class="text-muted-foreground">{{ DEFENSES[defenseType].name }}</span>
<span class="font-bold text-red-600 dark:text-red-400">-{{ count }}</span>
</div>
<p
v-if="
Object.keys(detail.defenderLosses.fleet).length === 0 && Object.keys(detail.defenderLosses.defense).length === 0
"
class="text-xs text-muted-foreground text-center py-1"
>
{{ t('messagesView.noLosses') }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</ScrollableDialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useGameStore } from '@/stores/gameStore'
import { useUniverseStore } from '@/stores/universeStore'
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { Dialog, ScrollableDialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import ResourceIcon from '@/components/common/ResourceIcon.vue'
import { formatNumber, formatDate } from '@/utils/format'
import { Sword, Shield as ShieldIcon, Package, Recycle, Moon, ListOrdered, ChevronDown, Clapperboard, FileText } from 'lucide-vue-next'
import BattleAnimationPlayer from './BattleAnimationPlayer.vue'
import type { BattleResult } from '@/types/game'
const props = defineProps<{
report: BattleResult | null
open: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const gameStore = useGameStore()
const universeStore = useUniverseStore()
const { t } = useI18n()
const { SHIPS, DEFENSES } = useGameConfig()
const isOpen = ref(props.open)
const showRoundDetails = ref(false)
const showAnimation = ref(false)
const animationPlayerRef = ref<InstanceType<typeof BattleAnimationPlayer> | null>(null)
const onAnimationComplete = () => {
// 动画完成后可以选择自动切换到详情视图
// showAnimation.value = false
}
// 获取攻击方星球信息
const attackerPlanet = computed(() => {
if (!props.report) return null
// 先从玩家星球中查找
const playerPlanet = gameStore.player.planets.find(p => p.id === props.report!.attackerPlanetId)
if (playerPlanet) return playerPlanet
// 再从宇宙星球地图中查找(包括 NPC 星球)
return Object.values(universeStore.planets).find(p => p.id === props.report!.attackerPlanetId)
})
// 获取防守方星球信息
const defenderPlanet = computed(() => {
if (!props.report) return null
// 先从玩家星球中查找
const playerPlanet = gameStore.player.planets.find(p => p.id === props.report!.defenderPlanetId)
if (playerPlanet) return playerPlanet
// 再从宇宙星球地图中查找(包括 NPC 星球)
return Object.values(universeStore.planets).find(p => p.id === props.report!.defenderPlanetId)
})
// 判断玩家是攻击方还是防守方
const isPlayerAttacker = computed(() => {
if (!props.report) return false
return gameStore.player.planets.some(p => p.id === props.report!.attackerPlanetId)
})
// 判断玩家是否胜利
const isPlayerVictory = computed(() => {
if (!props.report) return false
if (props.report.winner === 'draw') return false
// 玩家是攻击方且攻击方胜利,或者玩家是防守方且防守方胜利
return (isPlayerAttacker.value && props.report.winner === 'attacker') || (!isPlayerAttacker.value && props.report.winner === 'defender')
})
// 判断是否有任何剩余单位需要显示
const hasAnyRemaining = computed(() => {
if (!props.report) return false
const hasAttackerRemaining = props.report.attackerRemaining && Object.keys(props.report.attackerRemaining).length > 0
const hasDefenderRemaining =
props.report.defenderRemaining &&
(Object.keys(props.report.defenderRemaining.fleet || {}).length > 0 ||
Object.keys(props.report.defenderRemaining.defense || {}).length > 0)
return hasAttackerRemaining || hasDefenderRemaining
})
watch(
() => props.open,
newValue => {
isOpen.value = newValue
if (newValue) {
showRoundDetails.value = false
showAnimation.value = false
}
}
)
watch(isOpen, newValue => {
emit('update:open', newValue)
})
// 获取玩家战斗结果样式
const getPlayerResultStyle = () => {
if (!props.report) return 'bg-gray-50 dark:bg-gray-950/30 border-gray-200 dark:border-gray-800 text-gray-700 dark:text-gray-300'
if (props.report.winner === 'draw')
return 'bg-gray-50 dark:bg-gray-950/30 border-gray-200 dark:border-gray-800 text-gray-700 dark:text-gray-300'
if (isPlayerVictory.value)
return 'bg-green-50 dark:bg-green-950/30 border-green-300 dark:border-green-800 text-green-700 dark:text-green-300'
return 'bg-red-50 dark:bg-red-950/30 border-red-300 dark:border-red-800 text-red-700 dark:text-red-300'
}
// 获取攻击方损失总数
const getTotalLossCount = (losses: Record<string, number>): number => {
return Object.values(losses).reduce((sum, count) => sum + count, 0)
}
// 获取防守方损失总数
const getTotalDefenderLossCount = (losses: { fleet: Record<string, number>; defense: Record<string, number> }): number => {
const fleetLoss = Object.values(losses.fleet || {}).reduce((sum, count) => sum + count, 0)
const defenseLoss = Object.values(losses.defense || {}).reduce((sum, count) => sum + count, 0)
return fleetLoss + defenseLoss
}
// 获取防守方剩余总数
const getTotalDefenderRemainingCount = (remaining: { fleet?: Record<string, number>; defense?: Record<string, number> }): number => {
const fleetCount = Object.values(remaining.fleet || {}).reduce((sum, count) => sum + count, 0)
const defenseCount = Object.values(remaining.defense || {}).reduce((sum, count) => sum + count, 0)
return fleetCount + defenseCount
}
</script>

View File

@@ -29,7 +29,7 @@
import { Badge } from '@/components/ui/badge'
import { useDetailDialogStore } from '@/stores/detailDialogStore'
import { useI18n } from '@/composables/useI18n'
import ItemDetailView from './ItemDetailView.vue'
import ItemDetailView from '@/components/common/ItemDetailView.vue'
const { t } = useI18n()
const dialogStore = useDetailDialogStore()

View File

@@ -0,0 +1,346 @@
<template>
<Dialog v-model:open="isOpen">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<component :is="getMissionIcon(report?.missionType)" class="h-5 w-5" />
{{ t('messagesView.missionReportDetails') }}
</DialogTitle>
<DialogDescription>
{{ t('messagesView.missionDetails') }}
</DialogDescription>
</DialogHeader>
<div v-if="report" class="space-y-4">
<!-- 任务状态 -->
<div class="p-4 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-2">
<h3 class="font-semibold text-lg">{{ getMissionTypeName(report.missionType) }}</h3>
<Badge :variant="report.success ? 'default' : 'destructive'">
{{ report.success ? t('messagesView.missionSuccess') : t('messagesView.missionFailed') }}
</Badge>
</div>
<p class="text-sm text-muted-foreground mb-2">
{{ formatDate(report.timestamp) }}
</p>
<p class="text-sm">{{ report.message }}</p>
</div>
<!-- 起点和终点 -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('messagesView.origin') }}</h4>
<div class="p-3 bg-muted/30 rounded-md">
<p class="font-medium">{{ report.originPlanetName }}</p>
</div>
</div>
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('messagesView.destination') }}</h4>
<div class="p-3 bg-muted/30 rounded-md">
<p class="font-medium" v-if="report.targetPlanetName">{{ report.targetPlanetName }}</p>
<p class="text-sm text-muted-foreground" v-else>
[{{ report.targetPosition.galaxy }}:{{ report.targetPosition.system }}:{{ report.targetPosition.position }}]
</p>
</div>
</div>
</div>
<!-- 任务详情 -->
<div class="space-y-4">
<!-- 运输任务详情 -->
<div v-if="report.details?.transportedResources" class="space-y-3">
<div class="flex items-center gap-2">
<Package class="h-4 w-4" />
<h4 class="font-semibold text-sm">{{ t('messagesView.transportedResources') }}</h4>
</div>
<div class="grid grid-cols-3 gap-3">
<div v-for="res in basicResourceFields" :key="res.key" class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-1">
<ResourceIcon :type="res.key" size="sm" />
<span class="text-xs text-muted-foreground">{{ t(`resources.${res.key}`) }}</span>
</div>
<p class="text-lg font-bold">
{{ report.details.transportedResources[res.key].toLocaleString() }}
</p>
</div>
</div>
</div>
<!-- 回收任务详情 -->
<div v-if="report.details?.recycledResources" class="space-y-3">
<div class="flex items-center gap-2">
<Recycle class="h-4 w-4" />
<h4 class="font-semibold text-sm">{{ t('messagesView.recycledResources') }}</h4>
</div>
<div class="grid grid-cols-2 gap-3">
<div v-for="res in debrisResourceFields" :key="res.key" class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-1">
<ResourceIcon :type="res.key" size="sm" />
<span class="text-xs text-muted-foreground">{{ t(`resources.${res.key}`) }}</span>
</div>
<p class="text-lg font-bold">+{{ report.details.recycledResources[res.key].toLocaleString() }}</p>
</div>
</div>
<!-- 剩余残骸 -->
<div v-if="report.details.remainingDebris" class="mt-3">
<div class="flex items-center gap-2 mb-2">
<AlertTriangle class="h-4 w-4" />
<span class="text-sm font-medium text-muted-foreground">{{ t('messagesView.remainingDebris') }}</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div v-for="res in debrisResourceFields" :key="res.key" class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-1">
<ResourceIcon :type="res.key" size="sm" />
<span class="text-xs text-muted-foreground">{{ t(`resources.${res.key}`) }}</span>
</div>
<p class="text-lg font-bold">{{ report.details.remainingDebris[res.key].toLocaleString() }}</p>
</div>
</div>
</div>
</div>
<!-- 殖民任务详情 -->
<div v-if="report.details?.newPlanetName">
<p class="text-xs font-semibold text-muted-foreground">{{ t('messagesView.newPlanet') }}:</p>
<div class="flex items-center gap-2 mt-1">
<Globe class="h-4 w-4" />
<span class="font-medium">{{ report.details.newPlanetName }}</span>
</div>
</div>
<!-- 导弹攻击详情 -->
<div v-if="report.details?.missileCount !== undefined" class="space-y-4">
<!-- 导弹统计卡片 -->
<div class="grid grid-cols-3 gap-3">
<!-- 发射数量 -->
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-1">
<Rocket class="h-4 w-4" />
<span class="text-xs text-muted-foreground">{{ t('galaxyView.missileCount') }}</span>
</div>
<p class="text-xl font-bold">{{ report.details.missileCount }}</p>
</div>
<!-- 命中数量 -->
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-1">
<Target class="h-4 w-4" />
<span class="text-xs text-muted-foreground">{{ t('missionReports.hits') }}</span>
</div>
<p class="text-xl font-bold">{{ report.details.missileHits }}</p>
</div>
<!-- 被拦截数量 -->
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-1">
<ShieldAlert class="h-4 w-4" />
<span class="text-xs text-muted-foreground">{{ t('galaxyView.intercepted') }}</span>
</div>
<p class="text-xl font-bold">{{ report.details.missileIntercepted }}</p>
</div>
</div>
<!-- 防御损失 -->
<div v-if="Object.keys(report.details.defenseLosses || {}).length > 0">
<div class="flex items-center gap-2 mb-2">
<Flame class="h-4 w-4" />
<h4 class="font-semibold text-sm">{{ t('galaxyView.defenseLosses') }}</h4>
</div>
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div
v-for="(count, defenseType) in report.details.defenseLosses"
:key="defenseType"
class="flex items-center justify-between p-2 bg-white/50 dark:bg-black/20 rounded"
>
<span class="text-sm text-muted-foreground">{{ t('defenses.' + defenseType) }}</span>
<span class="font-bold text-destructive">-{{ count }}</span>
</div>
</div>
</div>
</div>
<!-- 无损失提示 -->
<div
v-else-if="report.details.missileHits === 0"
class="p-3 bg-muted/50 rounded-lg border flex items-center gap-2"
>
<ShieldCheck class="h-5 w-5" />
<span class="text-sm">{{ t('messagesView.noLosses') }}</span>
</div>
</div>
<!-- 探险任务详情 - 探险区域 -->
<div v-if="report.missionType === MissionType.Expedition && report.details?.expeditionZone" class="space-y-1">
<p class="text-xs font-semibold text-muted-foreground">{{ t('fleetView.expeditionZone') }}:</p>
<div class="p-2 bg-muted/50 rounded flex items-center gap-2">
<MapPin class="h-4 w-4 text-primary" />
<span class="font-medium">{{ t(`fleetView.zones.${report.details.expeditionZone}.name`) }}</span>
</div>
</div>
<!-- 探险任务详情 - 发现资源 -->
<div v-if="report.details?.foundResources" class="space-y-1">
<p class="text-xs font-semibold text-muted-foreground">{{ t('messagesView.resources') }}:</p>
<div class="grid grid-cols-2 gap-2 text-sm p-2 bg-muted/50 rounded">
<div v-for="res in allResourceFields" :key="res.key">
<template v-if="(report.details?.foundResources?.[res.key] ?? 0) > 0">
<span class="text-muted-foreground">{{ t(`resources.${res.key}`) }}:</span>
<span class="ml-1 font-medium">
+{{ (report.details?.foundResources?.[res.key] ?? 0).toLocaleString() }}
</span>
</template>
</div>
</div>
</div>
<!-- 探险任务详情 - 发现舰船 -->
<div v-if="report.details?.foundFleet" class="space-y-1">
<p class="text-xs font-semibold text-muted-foreground">{{ t('messagesView.fleet') }}:</p>
<div class="grid grid-cols-2 gap-2 text-sm p-2 bg-muted/50 rounded">
<div v-for="(count, shipType) in report.details.foundFleet" :key="shipType">
<span class="text-muted-foreground">{{ t('ships.' + shipType) }}:</span>
<span class="ml-1 font-medium">+{{ count }}</span>
</div>
</div>
</div>
<!-- 探险任务详情 - 损失舰船 -->
<div v-if="report.details?.fleetLost" class="space-y-1">
<p class="text-xs font-semibold text-muted-foreground">{{ t('messagesView.attackerLosses') }}:</p>
<div class="grid grid-cols-2 gap-2 text-sm p-2 bg-muted/50 rounded">
<div v-for="(count, shipType) in report.details.fleetLost" :key="shipType">
<span class="text-muted-foreground">{{ t('ships.' + shipType) }}:</span>
<span class="ml-1 font-medium text-destructive">-{{ count }}</span>
</div>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="isOpen = false">{{ t('common.close') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from '@/composables/useI18n'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import ResourceIcon from '@/components/common/ResourceIcon.vue'
import { formatDate } from '@/utils/format'
import {
Package,
Recycle,
AlertTriangle,
Globe,
Rocket,
Target,
ShieldAlert,
Flame,
ShieldCheck,
Truck,
Eye,
Sword,
Compass,
Skull,
MapPin
} from 'lucide-vue-next'
import { MissionType } from '@/types/game'
import type { MissionReport } from '@/types/game'
const props = defineProps<{
report: MissionReport | null
open: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const { t } = useI18n()
const isOpen = ref(props.open)
watch(
() => props.open,
newValue => {
isOpen.value = newValue
}
)
watch(isOpen, newValue => {
emit('update:open', newValue)
})
// 资源字段配置
type BasicResourceKey = 'metal' | 'crystal' | 'deuterium'
const basicResourceFields: { key: BasicResourceKey }[] = [{ key: 'metal' }, { key: 'crystal' }, { key: 'deuterium' }]
type DebrisResourceKey = 'metal' | 'crystal'
const debrisResourceFields: { key: DebrisResourceKey }[] = [{ key: 'metal' }, { key: 'crystal' }]
type AllResourceKey = 'metal' | 'crystal' | 'deuterium' | 'darkMatter'
const allResourceFields: { key: AllResourceKey }[] = [
{ key: 'metal' },
{ key: 'crystal' },
{ key: 'deuterium' },
{ key: 'darkMatter' }
]
// 获取任务类型名称
const getMissionTypeName = (missionType?: MissionType): string => {
if (missionType === undefined) return ''
switch (missionType) {
case MissionType.Transport:
return t('fleetView.transport')
case MissionType.Deploy:
return t('fleetView.deploy')
case MissionType.Attack:
return t('fleetView.attackMission')
case MissionType.Spy:
return t('fleetView.spy')
case MissionType.Colonize:
return t('fleetView.colonize')
case MissionType.Recycle:
return t('fleetView.recycle')
case MissionType.Expedition:
return t('fleetView.expedition')
case MissionType.Destroy:
return t('fleetView.destroy')
case MissionType.MissileAttack:
return t('galaxyView.missileAttack')
default:
return t('common.unknown')
}
}
// 获取任务图标
const getMissionIcon = (missionType?: MissionType) => {
if (missionType === undefined) return Package
switch (missionType) {
case MissionType.Transport:
return Truck
case MissionType.Deploy:
return Package
case MissionType.Attack:
return Sword
case MissionType.Spy:
return Eye
case MissionType.Colonize:
return Globe
case MissionType.Recycle:
return Recycle
case MissionType.MissileAttack:
return Rocket
case MissionType.Expedition:
return Compass
case MissionType.Destroy:
return Skull
default:
return Package
}
}
</script>

View File

@@ -0,0 +1,152 @@
<template>
<Dialog v-model:open="isOpen">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Recycle class="h-5 w-5" />
{{ t('messagesView.npcActivityDetails') }}
</DialogTitle>
<DialogDescription>
{{ t('messagesView.activityDescription') }}
</DialogDescription>
</DialogHeader>
<div v-if="notification" class="space-y-4">
<!-- NPC信息 -->
<div class="p-4 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-2">
<h3 class="font-semibold text-lg">{{ npcName }}</h3>
<Badge variant="secondary">{{ t('messagesView.activityType.' + notification.activityType) }}</Badge>
</div>
<p class="text-sm text-muted-foreground">
{{ formatDate(notification.timestamp) }}
</p>
</div>
<!-- 活动位置 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('messagesView.activityLocation') }}</h4>
<div class="p-3 bg-muted/30 rounded-md">
<div class="flex items-center gap-2 mb-2">
<Globe class="h-4 w-4" />
<span class="font-medium">
{{ t('messagesView.position') }}: [{{ notification.targetPosition.galaxy }}:{{
notification.targetPosition.system
}}:{{ notification.targetPosition.position }}]
</span>
</div>
<p v-if="notification.targetPlanetName" class="text-sm text-muted-foreground">
{{ t('messagesView.nearPlanet') }}: {{ notification.targetPlanetName }}
</p>
</div>
</div>
<!-- 活动描述 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('messagesView.activityDescription') }}</h4>
<div class="p-3 bg-muted/30 rounded-md">
<p class="text-sm">
{{
t('messagesView.npcActivityMessage', {
npc: npcName,
activity: t('messagesView.activityType.' + notification.activityType),
position: `[${notification.targetPosition.galaxy}:${notification.targetPosition.system}:${notification.targetPosition.position}]`
})
}}
</p>
</div>
</div>
<!-- 到达时间 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('messagesView.arrivalTime') }}</h4>
<div class="p-3 bg-muted/30 rounded-md">
<p class="font-medium">{{ formatDate(notification.arrivalTime) }}</p>
</div>
</div>
<!-- 提示信息 -->
<div class="p-3 bg-muted/50 rounded-md border">
<p class="text-sm text-muted-foreground">
{{ t('messagesView.npcActivityTip') }}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="isOpen = false">{{ t('common.close') }}</Button>
<Button @click="viewLocationInGalaxy">{{ t('messagesView.viewInGalaxy') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useNPCStore } from '@/stores/npcStore'
import { useI18n } from '@/composables/useI18n'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { formatDate } from '@/utils/format'
import { Recycle, Globe } from 'lucide-vue-next'
import type { NPCActivityNotification } from '@/types/game'
const props = defineProps<{
notification: NPCActivityNotification | null
open: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const router = useRouter()
const npcStore = useNPCStore()
const { t } = useI18n()
const isOpen = ref(props.open)
watch(
() => props.open,
newValue => {
isOpen.value = newValue
}
)
watch(isOpen, newValue => {
emit('update:open', newValue)
})
// 获取NPC名称
const npcName = computed(() => {
if (!props.notification) return ''
if (!npcStore.npcs?.length) return props.notification.npcName
// 通过 npcId 查找
if (props.notification.npcId) {
const npc = npcStore.npcs.find(n => n.id === props.notification!.npcId)
if (npc) return npc.name
}
// 尝试从旧名称中提取ID并查找
const idMatch = props.notification.npcName.match(/npc_\d+/)
if (idMatch) {
const extractedId = idMatch[0]
const npc = npcStore.npcs.find(n => n.id === extractedId)
if (npc) return npc.name
}
return props.notification.npcName
})
// 在银河系中查看位置
const viewLocationInGalaxy = () => {
if (!props.notification?.targetPosition) return
isOpen.value = false
router.push(
`/galaxy?galaxy=${props.notification.targetPosition.galaxy}&system=${props.notification.targetPosition.system}`
)
}
</script>

View File

@@ -81,7 +81,7 @@
import { useI18n } from '@/composables/useI18n'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import pkg from '../../package.json'
import pkg from '../../../package.json'
// open
const open = defineModel<boolean>('open', { default: false })

View File

@@ -0,0 +1,147 @@
<template>
<Dialog v-model:open="isOpen">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Eye class="h-5 w-5" />
{{ t('messagesView.spiedNotificationDetails') }}
</DialogTitle>
<DialogDescription class="sr-only">
{{ t('messagesView.spyDetected') }}
</DialogDescription>
</DialogHeader>
<div v-if="notification" class="space-y-4">
<!-- 侦查者信息 -->
<div class="p-4 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-3">
<div class="p-2 bg-background rounded-full border">
<AlertTriangle class="h-5 w-5" />
</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h3 class="font-semibold">{{ npcName }}</h3>
<Badge :variant="notification.detectionSuccess ? 'destructive' : 'secondary'" class="text-xs">
{{ notification.detectionSuccess ? t('messagesView.detected') : t('messagesView.undetected') }}
</Badge>
</div>
<p class="text-sm text-muted-foreground">
{{ formatDate(notification.timestamp) }}
</p>
</div>
</div>
</div>
<!-- 被侦查星球 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('messagesView.targetPlanet') }}</h4>
<div class="p-3 bg-muted/30 rounded-md border flex items-center gap-2">
<Globe class="h-4 w-4" />
<span class="font-medium">{{ notification.targetPlanetName }}</span>
</div>
</div>
<!-- 消息内容 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('messagesView.detectionResult') }}</h4>
<div class="p-3 bg-muted/30 rounded-md border">
<p class="text-sm">
{{
t('messagesView.spiedNotificationMessage', {
npc: npcName,
planet: notification.targetPlanetName
})
}}
</p>
</div>
</div>
<!-- 建议 -->
<div class="p-3 bg-muted/30 rounded-md border">
<p class="text-sm text-muted-foreground">
{{ t('messagesView.spiedNotificationTip') }}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="isOpen = false">{{ t('common.close') }}</Button>
<Button @click="viewNPCInGalaxy">{{ t('messagesView.viewInGalaxy') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useNPCStore } from '@/stores/npcStore'
import { useI18n } from '@/composables/useI18n'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { formatDate } from '@/utils/format'
import { Eye, AlertTriangle, Globe } from 'lucide-vue-next'
import type { SpiedNotification } from '@/types/game'
const props = defineProps<{
notification: SpiedNotification | null
open: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const router = useRouter()
const npcStore = useNPCStore()
const { t } = useI18n()
const isOpen = ref(props.open)
watch(
() => props.open,
newValue => {
isOpen.value = newValue
}
)
watch(isOpen, newValue => {
emit('update:open', newValue)
})
// 获取NPC名称
const npcName = computed(() => {
if (!props.notification) return ''
if (!npcStore.npcs?.length) return props.notification.npcName
// 通过 npcId 查找
if (props.notification.npcId) {
const npc = npcStore.npcs.find(n => n.id === props.notification!.npcId)
if (npc) return npc.name
}
// 尝试从旧名称中提取ID并查找
const idMatch = props.notification.npcName.match(/npc_\d+/)
if (idMatch) {
const extractedId = idMatch[0]
const npc = npcStore.npcs.find(n => n.id === extractedId)
if (npc) return npc.name
}
return props.notification.npcName
})
// 在银河系中查看NPC
const viewNPCInGalaxy = () => {
if (!props.notification?.npcId) return
const npc = npcStore.npcs.find(n => n.id === props.notification!.npcId)
if (npc && npc.planets && npc.planets.length > 0) {
isOpen.value = false
const homePlanet = npc.planets[0]?.position
if (homePlanet) {
router.push(`/galaxy?galaxy=${homePlanet.galaxy}&system=${homePlanet.system}`)
}
}
}
</script>

View File

@@ -0,0 +1,201 @@
<template>
<Dialog v-model:open="isOpen">
<ScrollableDialogContent container-class="sm:max-w-2xl max-h-[90vh]">
<template #header>
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Eye class="h-5 w-5" />
{{ t('messagesView.spyReport') }}
</DialogTitle>
<DialogDescription v-if="report">
{{ formatDate(report.timestamp) }}
</DialogDescription>
</DialogHeader>
</template>
<div v-if="report" class="space-y-4">
<!-- 目标星球信息 -->
<div class="p-4 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-3">
<div class="p-2 bg-background rounded-full border">
<Globe class="h-5 w-5" />
</div>
<div>
<p class="font-semibold">{{ report.targetPlanetName }}</p>
<p class="text-sm text-muted-foreground">
[{{ report.targetPosition.galaxy }}:{{ report.targetPosition.system }}:{{ report.targetPosition.position }}]
</p>
</div>
</div>
</div>
<!-- 资源 -->
<div class="space-y-3">
<div class="flex items-center gap-2">
<Coins class="h-4 w-4" />
<h4 class="font-semibold text-sm">{{ t('messagesView.resources') }}</h4>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<!-- 金属 -->
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-1">
<ResourceIcon type="metal" size="sm" />
<span class="text-xs text-muted-foreground">{{ t('resources.metal') }}</span>
</div>
<p class="text-lg font-bold">
{{ formatNumber(report.resources.metal) }}
</p>
</div>
<!-- 晶体 -->
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-1">
<ResourceIcon type="crystal" size="sm" />
<span class="text-xs text-muted-foreground">{{ t('resources.crystal') }}</span>
</div>
<p class="text-lg font-bold">
{{ formatNumber(report.resources.crystal) }}
</p>
</div>
<!-- -->
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-1">
<ResourceIcon type="deuterium" size="sm" />
<span class="text-xs text-muted-foreground">{{ t('resources.deuterium') }}</span>
</div>
<p class="text-lg font-bold">
{{ formatNumber(report.resources.deuterium) }}
</p>
</div>
<!-- 暗物质 -->
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="flex items-center gap-2 mb-1">
<ResourceIcon type="darkMatter" size="sm" />
<span class="text-xs text-muted-foreground">{{ t('resources.darkMatter') }}</span>
</div>
<p class="text-lg font-bold">
{{ formatNumber(report.resources.darkMatter) }}
</p>
</div>
</div>
</div>
<!-- 舰队如果有 -->
<div v-if="report.fleet && Object.keys(report.fleet).length > 0" class="space-y-3">
<div class="flex items-center gap-2">
<Rocket class="h-4 w-4" />
<h4 class="font-semibold text-sm">{{ t('messagesView.fleet') }}</h4>
<Badge variant="secondary" class="text-xs">{{ getTotalFleetCount(report.fleet) }}</Badge>
</div>
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div
v-for="(count, shipType) in report.fleet"
:key="shipType"
class="flex items-center justify-between p-2 bg-white/50 dark:bg-black/20 rounded"
>
<span class="text-sm text-muted-foreground truncate">{{ SHIPS[shipType].name }}</span>
<span class="font-bold">{{ count }}</span>
</div>
</div>
</div>
</div>
<!-- 防御设施如果有 -->
<div v-if="report.defense && hasDefense(report.defense)" class="space-y-3">
<div class="flex items-center gap-2">
<Shield class="h-4 w-4" />
<h4 class="font-semibold text-sm">{{ t('messagesView.defense') }}</h4>
<Badge variant="secondary" class="text-xs">{{ getTotalDefenseCount(report.defense) }}</Badge>
</div>
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div
v-for="(count, defenseType) in report.defense"
:key="defenseType"
class="flex items-center justify-between p-2 bg-white/50 dark:bg-black/20 rounded"
>
<template v-if="count && count > 0">
<span class="text-sm text-muted-foreground truncate">{{ DEFENSES[defenseType].name }}</span>
<span class="font-bold">{{ count }}</span>
</template>
</div>
</div>
</div>
</div>
<!-- 建筑如果有 -->
<div v-if="report.buildings && Object.keys(report.buildings).length > 0" class="space-y-3">
<div class="flex items-center gap-2">
<Building class="h-4 w-4" />
<h4 class="font-semibold text-sm">{{ t('messagesView.buildings') }}</h4>
</div>
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div
v-for="(level, buildingType) in report.buildings"
:key="buildingType"
class="flex items-center justify-between p-2 bg-white/50 dark:bg-black/20 rounded"
>
<span class="text-sm text-muted-foreground truncate">{{ BUILDINGS[buildingType].name }}</span>
<Badge variant="outline" class="font-bold">Lv.{{ level }}</Badge>
</div>
</div>
</div>
</div>
</div>
</ScrollableDialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { Dialog, ScrollableDialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import ResourceIcon from '@/components/common/ResourceIcon.vue'
import { formatNumber, formatDate } from '@/utils/format'
import { Eye, Globe, Coins, Rocket, Shield, Building } from 'lucide-vue-next'
import type { SpyReport } from '@/types/game'
const props = defineProps<{
report: SpyReport | null
open: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const { t } = useI18n()
const { SHIPS, DEFENSES, BUILDINGS } = useGameConfig()
const isOpen = ref(props.open)
watch(
() => props.open,
newValue => {
isOpen.value = newValue
}
)
watch(isOpen, newValue => {
emit('update:open', newValue)
})
// 检查是否有防御设施
const hasDefense = (defense: any): boolean => {
if (!defense) return false
return Object.values(defense).some((count: any) => count > 0)
}
// 获取舰队总数
const getTotalFleetCount = (fleet: Record<string, number>): number => {
return Object.values(fleet).reduce((sum, count) => sum + count, 0)
}
// 获取防御总数
const getTotalDefenseCount = (defense: Record<string, number>): number => {
return Object.values(defense).reduce((sum, count) => sum + (count || 0), 0)
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<Dialog :open="open" @update:open="$emit('update:open', $event)">
<DialogScrollContent class="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader class="flex-shrink-0">
<DialogHeader class="shrink-0">
<DialogTitle>{{ t('settings.newVersionAvailable', { version: versionInfo?.version || '' }) }}</DialogTitle>
<DialogDescription>{{ t('settings.updateAvailable') }}</DialogDescription>
</DialogHeader>
@@ -10,7 +10,7 @@
<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">
<DialogFooter class="flex gap-2 shrink-0 mt-4">
<Button variant="outline" @click="$emit('update:open', false)">
{{ t('common.cancel') }}
</Button>

View File

@@ -36,14 +36,14 @@
>
<div class="flex items-center gap-3">
<!-- 左侧事件图标 -->
<div class="flex-shrink-0">
<div class="shrink-0">
<component :is="getEventIcon(report.eventType)" class="h-5 w-5" :class="getEventIconColor(report.eventType)" />
</div>
<!-- 中间主要信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-sm truncate">{{ report.npcName }}</span>
<Badge :variant="getStatusBadgeVariant(report.newStatus)" class="text-xs flex-shrink-0">
<span class="font-medium text-sm truncate">{{ getNpcName(report) }}</span>
<Badge :variant="getStatusBadgeVariant(report.newStatus)" class="text-xs shrink-0">
{{ getStatusText(report.newStatus) }}
</Badge>
</div>
@@ -52,7 +52,7 @@
</p>
</div>
<!-- 右侧好感度变化和时间 -->
<div class="flex-shrink-0 text-right">
<div class="shrink-0 text-right">
<span
class="text-sm font-bold block"
:class="report.reputationChange >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'"
@@ -64,7 +64,7 @@
</span>
</div>
<!-- 未读标记 -->
<span v-if="!report.read" class="h-2 w-2 rounded-full bg-destructive flex-shrink-0" />
<span v-if="!report.read" class="h-2 w-2 rounded-full bg-destructive shrink-0" />
</div>
</div>
</div>
@@ -100,7 +100,7 @@
<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>
<h3 class="font-semibold text-lg">{{ getNpcName(selectedReport) }}</h3>
<Badge :variant="getStatusBadgeVariant(selectedReport.newStatus)">
{{ getStatusText(selectedReport.newStatus) }}
</Badge>
@@ -117,7 +117,7 @@
<p class="text-sm p-3 bg-muted/30 rounded-md">
{{
selectedReport.messageKey && selectedReport.messageParams
? t(selectedReport.messageKey, selectedReport.messageParams)
? t(selectedReport.messageKey, getMessageParams(selectedReport))
: selectedReport.message
}}
</p>
@@ -195,6 +195,7 @@
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useGameStore } from '@/stores/gameStore'
import { useNPCStore } from '@/stores/npcStore'
import { useI18n } from '@/composables/useI18n'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -209,6 +210,7 @@
const router = useRouter()
const gameStore = useGameStore()
const npcStore = useNPCStore()
const { t } = useI18n()
const isOpen = ref(false)
const detailDialogOpen = ref(false)
@@ -218,6 +220,42 @@
return (gameStore.player.diplomaticReports || []).slice().reverse().slice(0, 20) // 20
})
/**
* 获取NPC当前名称
* 优先使用当前NPC的实际名称如果NPC不存在则尝试从旧名称中提取ID查找
*/
const getNpcName = (report: DiplomaticReport): string => {
if (!npcStore.npcs?.length) return report.npcName
// 1. npcId
if (report.npcId) {
const npc = npcStore.npcs.find(n => n.id === report.npcId)
if (npc) return npc.name
}
// 2. ID
// "NPC-npc_182"ID "npc_182"
const idMatch = report.npcName.match(/npc_\d+/)
if (idMatch) {
const extractedId = idMatch[0]
const npc = npcStore.npcs.find(n => n.id === extractedId)
if (npc) return npc.name
}
return report.npcName
}
/**
* 获取报告的消息参数 npcName 替换为当前名称
*/
const getMessageParams = (report: DiplomaticReport): Record<string, string | number> => {
if (!report.messageParams) return {}
return {
...report.messageParams,
npcName: getNpcName(report)
}
}
const unreadCount = computed(() => {
return (gameStore.player.diplomaticReports || []).filter(r => !r.read).length
})

View File

@@ -36,14 +36,14 @@
>
<div class="flex items-center gap-3">
<!-- 左侧任务图标 -->
<div class="flex-shrink-0">
<div class="shrink-0">
<component :is="getMissionIcon(alert.missionType)" class="h-5 w-5" :class="getMissionIconColor(alert.missionType)" />
</div>
<!-- 中间主要信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-sm truncate">{{ alert.npcName }}</span>
<Badge :variant="getMissionBadgeVariant(alert.missionType)" class="text-xs flex-shrink-0">
<span class="font-medium text-sm truncate">{{ getNpcName(alert) }}</span>
<Badge :variant="getMissionBadgeVariant(alert.missionType)" class="text-xs shrink-0">
{{ getMissionTypeText(alert.missionType) }}
</Badge>
</div>
@@ -52,13 +52,13 @@
</p>
</div>
<!-- 右侧倒计时 -->
<div class="flex-shrink-0 text-right">
<div class="shrink-0 text-right">
<span class="text-sm font-bold block" :class="getRemainingTimeColor(alert)">
{{ formatRemainingTime(alert) }}
</span>
</div>
<!-- 未读标记 -->
<span v-if="!alert.read" class="h-2 w-2 rounded-full bg-destructive flex-shrink-0 animate-pulse" />
<span v-if="!alert.read" class="h-2 w-2 rounded-full bg-destructive shrink-0 animate-pulse" />
</div>
</div>
</div>
@@ -94,7 +94,7 @@
<div class="flex items-center gap-3 p-4 bg-destructive/10 rounded-lg">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h3 class="font-semibold text-lg">{{ selectedAlert.npcName }}</h3>
<h3 class="font-semibold text-lg">{{ getNpcName(selectedAlert) }}</h3>
<Badge :variant="getMissionBadgeVariant(selectedAlert.missionType)">
{{ getMissionTypeText(selectedAlert.missionType) }}
</Badge>
@@ -150,6 +150,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useGameStore } from '@/stores/gameStore'
import { useNPCStore } from '@/stores/npcStore'
import { useI18n } from '@/composables/useI18n'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -164,6 +165,7 @@
const router = useRouter()
const gameStore = useGameStore()
const npcStore = useNPCStore()
const { t } = useI18n()
const isOpen = ref(false)
const detailDialogOpen = ref(false)
@@ -193,6 +195,31 @@
.sort((a, b) => a.arrivalTime - b.arrivalTime) //
})
/**
* 获取NPC当前名称
* 优先使用当前NPC的实际名称如果NPC不存在则尝试从旧名称中提取ID查找
*/
const getNpcName = (alert: IncomingFleetAlert): string => {
if (!npcStore.npcs?.length) return alert.npcName
// 1. npcId
if (alert.npcId) {
const npc = npcStore.npcs.find(n => n.id === alert.npcId)
if (npc) return npc.name
}
// 2. ID
// "NPC-npc_182"ID "npc_182"
const idMatch = alert.npcName.match(/npc_\d+/)
if (idMatch) {
const extractedId = idMatch[0]
const npc = npcStore.npcs.find(n => n.id === extractedId)
if (npc) return npc.name
}
return alert.npcName
}
//
const getMissionIcon = (missionType: MissionType) => {
switch (missionType) {

View File

@@ -7,14 +7,11 @@
leave-from-class="translate-y-0 opacity-100"
leave-to-class="-translate-y-4 opacity-0"
>
<div
v-if="isHintVisible && currentHint"
class="fixed top-2 right-2 max-w-[280px] sm:top-4 sm:right-4 sm:max-w-xs z-50 pointer-events-auto"
>
<div v-if="isHintVisible && currentHint" class="fixed top-16 right-2 max-w-[280px] z-100 pointer-events-auto">
<div class="bg-card border rounded-lg shadow-lg p-3" role="alert" aria-live="polite">
<!-- 标题栏 -->
<div class="flex items-center gap-2 mb-2">
<component :is="getIcon(currentHint.icon)" class="h-4 w-4 text-primary flex-shrink-0" />
<component :is="getIcon(currentHint.icon)" class="h-4 w-4 text-primary shrink-0" />
<h4 class="font-medium text-sm">{{ t(currentHint.titleKey) }}</h4>
</div>

View File

@@ -3,7 +3,7 @@
<div class="px-4 sm:px-6 py-2 flex items-center justify-between gap-3">
<!-- 警告图标和汇总信息 -->
<div class="flex items-center gap-2 flex-1 min-w-0">
<AlertTriangle class="h-5 w-5 text-destructive flex-shrink-0 animate-pulse" />
<AlertTriangle class="h-5 w-5 text-destructive shrink-0 animate-pulse" />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-destructive">
{{ getAlertSummary() }}
@@ -15,7 +15,7 @@
</div>
<!-- 查看按钮 -->
<Button @click="openAlertPanel" variant="outline" size="sm" class="flex-shrink-0">
<Button @click="openAlertPanel" variant="outline" size="sm" class="shrink-0">
{{ t('common.view') }}
</Button>
</div>
@@ -63,12 +63,14 @@
//
const alertCounts = computed(() => {
const counts = { spy: 0, attack: 0, other: 0 }
const counts = { spy: 0, attack: 0, recycle: 0, other: 0 }
activeAlerts.value.forEach(alert => {
if (alert.missionType === MissionType.Spy) {
counts.spy++
} else if (alert.missionType === MissionType.Attack) {
counts.attack++
} else if (alert.missionType === MissionType.Recycle) {
counts.recycle++
} else {
counts.other++
}
@@ -85,6 +87,9 @@
if (alertCounts.value.spy > 0) {
parts.push(`${alertCounts.value.spy} ${t('enemyAlert.missionType.spy')}`)
}
if (alertCounts.value.recycle > 0) {
parts.push(`${alertCounts.value.recycle} ${t('enemyAlert.missionType.recycle')}`)
}
if (alertCounts.value.other > 0) {
parts.push(`${alertCounts.value.other} ${t('enemyAlert.missionType.unknown')}`)
}

View File

@@ -3,7 +3,7 @@
<div class="px-4 sm:px-6 py-2 flex items-center justify-between gap-3">
<!-- 警告图标和信息 -->
<div class="flex items-center gap-2 flex-1 min-w-0">
<Zap class="h-5 w-5 text-destructive flex-shrink-0 animate-pulse" />
<Zap class="h-5 w-5 text-destructive shrink-0 animate-pulse" />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-destructive">
{{ t('energy.lowWarning') }}
@@ -15,7 +15,7 @@
</div>
<!-- 建造电站按钮 -->
<Button @click="goToBuildSolarPlant" variant="outline" size="sm" class="flex-shrink-0">
<Button @click="goToBuildSolarPlant" variant="outline" size="sm" class="shrink-0">
{{ t('energy.buildSolarPlant') }}
</Button>
</div>

View File

@@ -0,0 +1,119 @@
<template>
<div v-if="showWarning" class="bg-amber-500/10 border-b border-amber-500/20">
<div class="px-4 sm:px-6 py-2 flex items-center justify-between gap-3">
<!-- 警告图标和信息 -->
<div class="flex items-center gap-2 flex-1 min-w-0">
<Mountain class="h-5 w-5 text-amber-500 shrink-0 animate-pulse" />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-amber-500">
{{ warningTitle }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ detailMessage }}
</p>
</div>
</div>
<!-- 查看详情按钮 -->
<Button @click="goToBuildings" variant="outline" size="sm" class="shrink-0">
{{ t('common.viewDetails') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useGameStore } from '@/stores/gameStore'
import { Button } from '@/components/ui/button'
import { Mountain } from 'lucide-vue-next'
import { useI18n } from '@/composables/useI18n'
import * as oreDepositLogic from '@/logic/oreDepositLogic'
const gameStore = useGameStore()
const router = useRouter()
const { t } = useI18n()
// 获取当前星球
const planet = computed(() => gameStore.currentPlanet)
// 检查各资源的矿脉状态
const depositStatus = computed(() => {
if (!planet.value || planet.value.isMoon) return null
const deposits = planet.value.oreDeposits
if (!deposits) return null
const resources = ['metal', 'crystal', 'deuterium'] as const
const warnings: { type: string; depleted: boolean; percentage: number }[] = []
for (const resource of resources) {
const isDepleted = oreDepositLogic.isDepositDepleted(deposits, resource)
const isWarning = oreDepositLogic.isDepositWarning(deposits, resource)
const percentage = oreDepositLogic.getDepositPercentage(deposits, resource)
if (isDepleted || isWarning) {
warnings.push({
type: resource,
depleted: isDepleted,
percentage: Math.round(percentage)
})
}
}
return warnings.length > 0 ? warnings : null
})
// 是否显示警告
const showWarning = computed(() => {
return depositStatus.value !== null && depositStatus.value.length > 0
})
// 获取资源名称翻译
const getResourceName = (type: string): string => {
const resourceNames: Record<string, string> = {
metal: t('resources.metal'),
crystal: t('resources.crystal'),
deuterium: t('resources.deuterium')
}
return resourceNames[type] || type
}
// 警告标题
const warningTitle = computed(() => {
if (!depositStatus.value) return ''
const hasDepleted = depositStatus.value.some(s => s.depleted)
if (hasDepleted) {
return t('oreDeposit.depletedWarning')
}
return t('oreDeposit.lowWarning')
})
// 详细消息
const detailMessage = computed(() => {
if (!depositStatus.value) return ''
const depletedResources = depositStatus.value.filter(s => s.depleted).map(s => getResourceName(s.type))
const warningResources = depositStatus.value.filter(s => !s.depleted).map(s => `${getResourceName(s.type)} (${s.percentage}%)`)
const parts: string[] = []
if (depletedResources.length > 0) {
parts.push(t('oreDeposit.depletedResources', { resources: depletedResources.join(', ') }))
}
if (warningResources.length > 0) {
parts.push(t('oreDeposit.lowResources', { resources: warningResources.join(', ') }))
}
return parts.join(' | ')
})
// 跳转到建筑页面查看详情
const goToBuildings = () => {
router.push('/galaxy')
}
</script>

View File

@@ -0,0 +1,317 @@
<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 }}
</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') }} ({{ totalQueueCount }})</h3>
</div>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="w-full grid grid-cols-6 h-auto min-h-9 rounded-none border-b bg-transparent">
<TabsTrigger
v-for="tab in tabConfig"
:key="tab.value"
:value="tab.value"
class="text-xs px-1 py-1.5 flex items-center justify-center gap-0.5 whitespace-nowrap data-[state=active]:bg-muted"
>
<span class="truncate">{{ t(`queue.tabs.${tab.value}`) }}</span>
<Badge v-if="tab.items.length > 0" variant="secondary" class="shrink-0 h-4 px-1 text-[10px]">
{{ tab.items.length }}
</Badge>
</TabsTrigger>
</TabsList>
<ScrollArea class="h-[420px]">
<TabsContent v-for="tab in tabConfig" :key="tab.value" :value="tab.value" class="mt-0">
<Empty v-if="tab.items.length === 0" class="border-0">
<EmptyContent>
<Inbox class="h-10 w-10 text-muted-foreground" />
<EmptyDescription>{{ tab.isWaiting ? t('queue.waitingEmpty') : t('queue.empty') }}</EmptyDescription>
</EmptyContent>
</Empty>
<div v-else class="divide-y p-4 space-y-3">
<!-- 等待队列项 -->
<template v-if="tab.isWaiting">
<div v-for="item in tab.items" :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 shrink-0" :class="getStatusDotClass(item)" />
<span class="font-medium truncate">{{ getItemName(item) }}</span>
<span class="text-muted-foreground text-[10px] sm:text-xs">
{{
item.type === 'ship' || item.type === 'defense'
? `${t('queue.quantity')} ${item.quantity}`
: item.type === 'demolish'
? `${t('queue.demolishing')}`
: `${t('queue.level')} ${item.targetLevel}`
}}
</span>
</div>
<div class="flex items-center gap-2 sm:gap-3 shrink-0">
<span
class="text-[10px] sm:text-xs whitespace-nowrap"
:class="isWaitingItemResourcesReady(item as WaitingQueueItem) ? 'text-green-500' : 'text-yellow-500'"
>
{{ isWaitingItemResourcesReady(item as WaitingQueueItem) ? t('queue.resourcesReady') : t('queue.waitingResources') }}
</span>
<Button
variant="ghost"
size="sm"
class="h-5 sm:h-6 px-1.5 sm:px-2 text-[10px] sm:text-xs"
@click.stop="handleCancel(item)"
>
{{ t('queue.remove') }}
</Button>
</div>
</div>
<!-- 预估成本显示 -->
<div class="flex gap-2 text-[10px] text-muted-foreground ml-4">
<span v-if="getWaitingItemCost(item as WaitingQueueItem).metal > 0">
{{ t('resources.metal') }}: {{ formatNumber(getWaitingItemCost(item as WaitingQueueItem).metal) }}
</span>
<span v-if="getWaitingItemCost(item as WaitingQueueItem).crystal > 0">
{{ t('resources.crystal') }}: {{ formatNumber(getWaitingItemCost(item as WaitingQueueItem).crystal) }}
</span>
<span v-if="getWaitingItemCost(item as WaitingQueueItem).deuterium > 0">
{{ t('resources.deuterium') }}: {{ formatNumber(getWaitingItemCost(item as WaitingQueueItem).deuterium) }}
</span>
</div>
</div>
</template>
<!-- 正式队列项 -->
<template v-else>
<div v-for="item in tab.items" :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 shrink-0" :class="getStatusDotClass(item)" />
<span class="font-medium truncate">{{ getItemName(item) }}</span>
<span class="text-muted-foreground text-[10px] sm:text-xs">
{{
item.type === 'ship' || item.type === 'defense'
? `${t('queue.quantity')} ${item.quantity}`
: item.type === 'demolish'
? `${t('queue.demolishing')}`
: `${t('queue.level')} ${item.targetLevel}`
}}
</span>
</div>
<div class="flex items-center gap-2 sm:gap-3 shrink-0">
<span class="text-muted-foreground text-[10px] sm:text-xs whitespace-nowrap">
{{ formatTime(getRemainingTime(item as BuildQueueItem)) }}
</span>
<Button
variant="ghost"
size="sm"
class="h-5 sm:h-6 px-1.5 sm:px-2 text-[10px] sm:text-xs"
@click.stop="handleCancel(item)"
>
{{ t('queue.cancel') }}
</Button>
</div>
</div>
<Progress :model-value="getQueueProgress(item as BuildQueueItem)" class="h-1.5" />
</div>
</template>
</div>
</TabsContent>
</ScrollArea>
</Tabs>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { computed, ref, onUnmounted, watch } from 'vue'
import { ListOrdered, Inbox } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Empty, EmptyContent, EmptyDescription } from '@/components/ui/empty'
import { useGameStore } from '@/stores/gameStore'
import { useGameConfig } from '@/composables/useGameConfig'
import { useI18n } from '@/composables/useI18n'
import { formatTime, formatNumber } from '@/utils/format'
import type { BuildQueueItem, WaitingQueueItem, BuildingType, ShipType, DefenseType, TechnologyType, Resources } from '@/types/game'
import * as waitingQueueLogic from '@/logic/waitingQueueLogic'
import * as resourceLogic from '@/logic/resourceLogic'
const { t } = useI18n()
const gameStore = useGameStore()
const { BUILDINGS, SHIPS, DEFENSES, TECHNOLOGIES } = useGameConfig()
const isOpen = ref(false)
const activeTab = ref('all')
// 响应式时间戳,用于驱动时间和进度的动态更新
const currentTime = ref(Date.now())
let timerInterval: ReturnType<typeof setInterval> | null = null
// 当弹窗打开时启动计时器,关闭时停止
watch(isOpen, open => {
if (open) {
// 启动每秒更新的计时器
timerInterval = setInterval(() => {
currentTime.value = Date.now()
}, 1000)
} else {
// 停止计时器
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
}
})
// 组件卸载时清理计时器
onUnmounted(() => {
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
})
// 获取当前星球的建造队列
const buildQueue = computed(() => {
return gameStore.currentPlanet?.buildQueue || []
})
// 获取研究队列
const researchQueue = computed(() => {
return gameStore.player.researchQueue || []
})
// 获取当前星球的建造等待队列
const buildWaitingQueue = computed(() => {
return gameStore.currentPlanet?.waitingBuildQueue || []
})
// 获取研究等待队列
const researchWaitingQueue = computed(() => {
return gameStore.player.waitingResearchQueue || []
})
// 合并所有等待队列
const allWaitingQueue = computed(() => {
return [...buildWaitingQueue.value, ...researchWaitingQueue.value]
})
// 总队列数量(包括等待队列)
const totalQueueCount = computed(() => {
return buildQueue.value.length + researchQueue.value.length + allWaitingQueue.value.length
})
// 标签页配置(用于循环渲染)
const tabConfig = computed(() => [
{ value: 'all', items: [...buildQueue.value, ...researchQueue.value], isWaiting: false },
{ value: 'buildings', items: buildQueue.value.filter(item => item.type === 'building' || item.type === 'demolish'), isWaiting: false },
{ value: 'research', items: researchQueue.value, isWaiting: false },
{ value: 'ships', items: buildQueue.value.filter(item => item.type === 'ship'), isWaiting: false },
{ value: 'defense', items: buildQueue.value.filter(item => item.type === 'defense'), isWaiting: false },
{ value: 'waiting', items: allWaitingQueue.value, isWaiting: true }
])
// 获取队列项名称
const getItemName = (item: BuildQueueItem | WaitingQueueItem): 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 isWaitingItem = (item: BuildQueueItem | WaitingQueueItem): item is WaitingQueueItem => {
return 'addedTime' in item && 'priority' in item
}
// 获取等待队列项的预估成本
const getWaitingItemCost = (item: WaitingQueueItem): Resources => {
return waitingQueueLogic.calculateWaitingItemCost(item)
}
// 检查等待队列项资源是否足够
const isWaitingItemResourcesReady = (item: WaitingQueueItem): boolean => {
const cost = getWaitingItemCost(item)
const resources = gameStore.currentPlanet?.resources
if (!resources) return false
return resourceLogic.checkResourcesAvailable(resources, cost)
}
// 获取剩余时间(使用响应式 currentTime 确保动态更新)
const getRemainingTime = (item: BuildQueueItem): number => {
return Math.max(0, Math.floor((item.endTime - currentTime.value) / 1000))
}
// 获取队列进度(使用响应式 currentTime 确保动态更新)
const getQueueProgress = (item: BuildQueueItem): number => {
const elapsed = currentTime.value - item.startTime
const total = item.endTime - item.startTime
if (total <= 0) return 100
return Math.max(0, Math.min(100, (elapsed / total) * 100))
}
// 统一的取消处理
const handleCancel = (item: BuildQueueItem | WaitingQueueItem) => {
// 检查是否是等待队列项
if (isWaitingItem(item)) {
handleRemoveFromWaiting(item)
return
}
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 handleRemoveFromWaiting = (item: WaitingQueueItem) => {
const planet = gameStore.currentPlanet
if (!planet) return
if (item.type === 'technology') {
// 从研究等待队列移除
waitingQueueLogic.removeFromResearchWaitingQueue(gameStore.player, item.id)
} else {
// 从建筑等待队列移除
waitingQueueLogic.removeFromBuildWaitingQueue(planet, item.id)
}
}
// 获取状态指示点颜色
const getStatusDotClass = (item: BuildQueueItem | WaitingQueueItem): string => {
// 等待队列项根据资源是否足够显示不同颜色
if (isWaitingItem(item)) {
return isWaitingItemResourcesReady(item) ? 'bg-green-500' : 'bg-yellow-500'
}
if (item.type === 'demolish') return 'bg-destructive'
if (item.type === 'technology') return 'bg-blue-500'
return 'bg-green-500'
}
</script>

View File

@@ -5,7 +5,7 @@
<div class="hidden sm:flex items-center gap-3">
<!-- 状态指示器 -->
<div
class="w-2 h-2 rounded-full flex-shrink-0"
class="w-2 h-2 rounded-full shrink-0"
:class="{
'bg-green-500': status === RelationStatus.Friendly,
'bg-red-500': status === RelationStatus.Hostile,
@@ -35,7 +35,7 @@
</div>
<!-- 好感度 -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="flex items-center gap-2 shrink-0">
<div class="w-16 h-1.5 bg-muted rounded-full overflow-hidden relative">
<div v-if="reputation < 0" class="h-full bg-red-500 absolute right-1/2" :style="{ width: `${Math.abs(reputation) / 2}%` }" />
<div v-if="reputation > 0" class="h-full bg-green-500 absolute left-1/2" :style="{ width: `${reputation / 2}%` }" />
@@ -45,7 +45,7 @@
</div>
<!-- 操作按钮 -->
<div class="flex items-center gap-1 flex-shrink-0">
<div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" class="h-8 w-8" @click.stop="handleGiftResources" :title="t('diplomacy.actions.gift')">
<Gift class="h-4 w-4" />
</Button>
@@ -70,7 +70,7 @@
<!-- 第一行状态名称展开箭头 -->
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full flex-shrink-0"
class="w-2 h-2 rounded-full shrink-0"
:class="{
'bg-green-500': status === RelationStatus.Friendly,
'bg-red-500': status === RelationStatus.Hostile,
@@ -90,7 +90,7 @@
Lv.{{ npc.difficultyLevel }}
</Badge>
</div>
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform flex-shrink-0" :class="{ 'rotate-180': isExpanded }" />
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform shrink-0" :class="{ 'rotate-180': isExpanded }" />
</div>
<!-- 第二行星球数好感度操作按钮 -->

View File

@@ -1,6 +1,6 @@
<template>
<div
:class="cn('relative size-full overflow-hidden bg-[radial-gradient(ellipse_at_bottom,_#262626_0%,_#000_100%)]', props.class)"
:class="cn('relative size-full overflow-hidden bg-[radial-gradient(ellipse_at_bottom,#262626_0%,#000_100%)]', props.class)"
@mousemove="handleMouseMove"
>
<motion.div :style="{ x: springX, y: springY }">

View File

@@ -5,7 +5,7 @@
v-bind="forwarded"
:class="
cn(
'peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
'peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-sm border shadow-xs transition-shadow outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50',
props.class
)
"

View File

@@ -6,7 +6,7 @@
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[60] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-2xl',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-1/2 left-1/2 z-60 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-2xl',
props.class
)
"

View File

@@ -4,7 +4,7 @@
v-bind="delegatedProps"
:class="
cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[60] bg-black/80',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-60 bg-black/80',
props.class
)
"

View File

@@ -6,7 +6,7 @@
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[60] w-[calc(100vw-3rem)] translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200 sm:w-auto sm:min-w-[764px] flex flex-col p-0',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-1/2 left-1/2 z-60 w-[calc(100vw-3rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-lg duration-200 sm:w-auto sm:min-w-[764px] flex flex-col p-0',
containerClass
)
"

View File

@@ -1,19 +1,15 @@
<script setup lang="ts">
import type { PopoverRootEmits, PopoverRootProps } from "reka-ui"
import { PopoverRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<PopoverRootProps>()
const emits = defineEmits<PopoverRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<PopoverRoot
v-slot="slotProps"
data-slot="popover"
v-bind="forwarded"
>
<PopoverRoot v-slot="slotProps" data-slot="popover" v-bind="forwarded">
<slot v-bind="slotProps" />
</PopoverRoot>
</template>
<script setup lang="ts">
import type { PopoverRootEmits, PopoverRootProps } from 'reka-ui'
import { PopoverRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<PopoverRootProps>()
const emits = defineEmits<PopoverRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>

View File

@@ -1,15 +1,12 @@
<script setup lang="ts">
import type { PopoverAnchorProps } from "reka-ui"
import { PopoverAnchor } from "reka-ui"
const props = defineProps<PopoverAnchorProps>()
</script>
<template>
<PopoverAnchor
data-slot="popover-anchor"
v-bind="props"
>
<PopoverAnchor data-slot="popover-anchor" v-bind="props">
<slot />
</PopoverAnchor>
</template>
<script setup lang="ts">
import type { PopoverAnchorProps } from 'reka-ui'
import { PopoverAnchor } from 'reka-ui'
const props = defineProps<PopoverAnchorProps>()
</script>

View File

@@ -1,32 +1,3 @@
<script setup lang="ts">
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
PopoverContent,
PopoverPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<PopoverContentProps & { class?: HTMLAttributes["class"] }>(),
{
align: "center",
sideOffset: 4,
},
)
const emits = defineEmits<PopoverContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PopoverPortal>
<PopoverContent
@@ -35,7 +6,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
props.class,
props.class
)
"
>
@@ -43,3 +14,25 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
</PopoverContent>
</PopoverPortal>
</template>
<script setup lang="ts">
import type { PopoverContentEmits, PopoverContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { PopoverContent, PopoverPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false
})
const props = withDefaults(defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>(), {
align: 'center',
sideOffset: 4
})
const emits = defineEmits<PopoverContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>

View File

@@ -1,15 +1,12 @@
<script setup lang="ts">
import type { PopoverTriggerProps } from "reka-ui"
import { PopoverTrigger } from "reka-ui"
const props = defineProps<PopoverTriggerProps>()
</script>
<template>
<PopoverTrigger
data-slot="popover-trigger"
v-bind="props"
>
<PopoverTrigger data-slot="popover-trigger" v-bind="props">
<slot />
</PopoverTrigger>
</template>
<script setup lang="ts">
import type { PopoverTriggerProps } from 'reka-ui'
import { PopoverTrigger } from 'reka-ui'
const props = defineProps<PopoverTriggerProps>()
</script>

View File

@@ -1,4 +1,4 @@
export { default as Popover } from "./Popover.vue"
export { default as PopoverAnchor } from "./PopoverAnchor.vue"
export { default as PopoverContent } from "./PopoverContent.vue"
export { default as PopoverTrigger } from "./PopoverTrigger.vue"
export { default as Popover } from './Popover.vue'
export { default as PopoverAnchor } from './PopoverAnchor.vue'
export { default as PopoverContent } from './PopoverContent.vue'
export { default as PopoverTrigger } from './PopoverTrigger.vue'

View File

@@ -5,7 +5,7 @@
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-32 overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class
@@ -17,7 +17,7 @@
:class="
cn(
'p-1',
position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1'
position === 'popper' && 'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width) scroll-my-1'
)
"
>

View File

@@ -4,7 +4,7 @@
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class
)
"

View File

@@ -5,11 +5,11 @@
</template>
<script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
const forwarded = useForwardPropsEmits(props, emits)
</script>

View File

@@ -1,5 +1,5 @@
<template>
<TooltipPortal>
<TooltipPortal to="#app">
<TooltipContent
data-slot="tooltip-content"
v-bind="{ ...forwarded, ...$attrs }"
@@ -18,22 +18,22 @@
</template>
<script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false
})
defineOptions({
inheritAttrs: false
})
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(), {
sideOffset: 4
})
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(), {
sideOffset: 4
})
const emits = defineEmits<TooltipContentEmits>()
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>

View File

@@ -5,10 +5,10 @@
</template>
<script setup lang="ts">
import type { TooltipProviderProps } from 'reka-ui'
import { TooltipProvider } from 'reka-ui'
import type { TooltipProviderProps } from 'reka-ui'
import { TooltipProvider } from 'reka-ui'
const props = withDefaults(defineProps<TooltipProviderProps>(), {
delayDuration: 0
})
const props = withDefaults(defineProps<TooltipProviderProps>(), {
delayDuration: 0
})
</script>

View File

@@ -5,8 +5,8 @@
</template>
<script setup lang="ts">
import type { TooltipTriggerProps } from 'reka-ui'
import { TooltipTrigger } from 'reka-ui'
import type { TooltipTriggerProps } from 'reka-ui'
import { TooltipTrigger } from 'reka-ui'
const props = defineProps<TooltipTriggerProps>()
const props = defineProps<TooltipTriggerProps>()
</script>