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

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

@@ -0,0 +1,64 @@
<template>
<Dialog :open="dialogStore.isOpen" @update:open="handleClose">
<ScrollableDialogContent
v-if="dialogStore.type && dialogStore.itemType"
container-class="sm:max-w-[90vw] md:max-w-3xl lg:max-w-4xl max-h-[90vh]"
>
<template #header>
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
{{ itemTitle }}
<Badge v-if="dialogStore.currentLevel !== undefined" variant="outline">
{{ t('common.currentLevel') }} {{ dialogStore.currentLevel }}
</Badge>
</DialogTitle>
<DialogDescription>
{{ itemDescription }}
</DialogDescription>
</DialogHeader>
</template>
<ItemDetailView :type="dialogStore.type" :itemType="dialogStore.itemType" :currentLevel="dialogStore.currentLevel" />
</ScrollableDialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Dialog, ScrollableDialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { useDetailDialogStore } from '@/stores/detailDialogStore'
import { useI18n } from '@/composables/useI18n'
import ItemDetailView from '@/components/common/ItemDetailView.vue'
const { t } = useI18n()
const dialogStore = useDetailDialogStore()
const itemTitle = computed(() => {
if (!dialogStore.type || !dialogStore.itemType) return ''
const typeMap = {
building: 'buildings',
technology: 'technologies',
ship: 'ships',
defense: 'defenses'
}
return t(`${typeMap[dialogStore.type]}.${dialogStore.itemType}`)
})
const itemDescription = computed(() => {
if (!dialogStore.type || !dialogStore.itemType) return ''
const typeMap = {
building: 'buildingDescriptions',
technology: 'technologyDescriptions',
ship: 'shipDescriptions',
defense: 'defenseDescriptions'
}
return t(`${typeMap[dialogStore.type]}.${dialogStore.itemType}`)
})
const handleClose = (open: boolean) => {
if (!open) {
dialogStore.close()
}
}
</script>

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

@@ -0,0 +1,90 @@
<template>
<Dialog v-model:open="open">
<DialogContent class="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{{ t('privacy.title') }}</DialogTitle>
<DialogDescription class="sr-only">{{ t('privacy.title') }}</DialogDescription>
</DialogHeader>
<div class="flex-1 overflow-y-auto pr-2 space-y-4 text-sm">
<!-- 简介 -->
<section>
<h3 class="font-semibold mb-1">{{ t('privacy.sections.introduction.title') }}</h3>
<p class="text-muted-foreground">{{ t('privacy.sections.introduction.content') }}</p>
</section>
<!-- 数据收集 -->
<section>
<h3 class="font-semibold mb-1">{{ t('privacy.sections.dataCollection.title') }}</h3>
<p class="text-muted-foreground mb-1">{{ t('privacy.sections.dataCollection.content') }}</p>
<ul class="list-disc list-inside text-muted-foreground ml-2 space-y-0.5">
<li>{{ t('privacy.sections.dataCollection.items.gameProgress') }}</li>
<li>{{ t('privacy.sections.dataCollection.items.settings') }}</li>
<li>{{ t('privacy.sections.dataCollection.items.language') }}</li>
</ul>
</section>
<!-- 数据存储 -->
<section>
<h3 class="font-semibold mb-1">{{ t('privacy.sections.dataStorage.title') }}</h3>
<p class="text-muted-foreground">{{ t('privacy.sections.dataStorage.content') }}</p>
</section>
<!-- 无服务器通信 -->
<section>
<h3 class="font-semibold mb-1">{{ t('privacy.sections.noServer.title') }}</h3>
<p class="text-muted-foreground">{{ t('privacy.sections.noServer.content') }}</p>
</section>
<!-- 第三方服务 -->
<section>
<h3 class="font-semibold mb-1">{{ t('privacy.sections.thirdParty.title') }}</h3>
<p class="text-muted-foreground">{{ t('privacy.sections.thirdParty.content') }}</p>
</section>
<!-- 数据控制 -->
<section>
<h3 class="font-semibold mb-1">{{ t('privacy.sections.dataControl.title') }}</h3>
<p class="text-muted-foreground mb-1">{{ t('privacy.sections.dataControl.content') }}</p>
<ul class="list-disc list-inside text-muted-foreground ml-2 space-y-0.5">
<li>{{ t('privacy.sections.dataControl.items.export') }}</li>
<li>{{ t('privacy.sections.dataControl.items.import') }}</li>
<li>{{ t('privacy.sections.dataControl.items.delete') }}</li>
</ul>
</section>
<!-- 联系我们 -->
<section>
<h3 class="font-semibold mb-1">{{ t('privacy.sections.contact.title') }}</h3>
<p class="text-muted-foreground">
{{ t('privacy.sections.contact.content') }}
<a
:href="`https://github.com/${pkg.author.name}/${pkg.name}/issues`"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
GitHub Issues
</a>
</p>
</section>
</div>
<DialogFooter class="mt-4">
<Button variant="outline" @click="open = false">
{{ t('common.close') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
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'
// 双向绑定 open 状态
const open = defineModel<boolean>('open', { default: false })
const { t } = useI18n()
</script>

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

@@ -0,0 +1,120 @@
<template>
<Dialog :open="open" @update:open="$emit('update:open', $event)">
<DialogScrollContent class="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader class="shrink-0">
<DialogTitle>{{ t('settings.newVersionAvailable', { version: versionInfo?.version || '' }) }}</DialogTitle>
<DialogDescription>{{ t('settings.updateAvailable') }}</DialogDescription>
</DialogHeader>
<div class="flex-1 overflow-y-auto min-h-0 mt-4 pr-2">
<div class="prose prose-sm dark:prose-invert max-w-none" v-html="renderedMarkdown" />
</div>
<DialogFooter class="flex gap-2 shrink-0 mt-4">
<Button variant="outline" @click="$emit('update:open', false)">
{{ t('common.cancel') }}
</Button>
<Button @click="handleDownload">
<Download class="mr-2 h-4 w-4" />
{{ t('settings.download') }}
</Button>
</DialogFooter>
</DialogScrollContent>
</Dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import { useI18n } from '@/composables/useI18n'
import { Dialog, DialogScrollContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Download } from 'lucide-vue-next'
import type { VersionInfo } from '@/utils/versionCheck'
const props = defineProps<{
open: boolean
versionInfo: VersionInfo | null
}>()
defineEmits<{
'update:open': [value: boolean]
}>()
const { t } = useI18n()
const renderedMarkdown = computed(() => {
if (!props.versionInfo?.releaseNotes) return ''
return marked(props.versionInfo.releaseNotes)
})
const handleDownload = () => {
if (props.versionInfo?.downloadUrl) {
window.open(props.versionInfo.downloadUrl, '_blank')
}
}
</script>
<style scoped>
:deep(.prose) {
color: hsl(var(--foreground));
}
:deep(.prose h1) {
font-size: 1.5em;
font-weight: 700;
margin-top: 1em;
margin-bottom: 0.5em;
}
:deep(.prose h2) {
font-size: 1.25em;
font-weight: 600;
margin-top: 0.8em;
margin-bottom: 0.4em;
}
:deep(.prose h3) {
font-size: 1.1em;
font-weight: 600;
margin-top: 0.6em;
margin-bottom: 0.3em;
}
:deep(.prose p) {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
:deep(.prose ul) {
margin-top: 0.5em;
margin-bottom: 0.5em;
padding-left: 1.5em;
}
:deep(.prose li) {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
:deep(.prose code) {
background: hsl(var(--muted));
padding: 0.2em 0.4em;
border-radius: 0.25rem;
font-size: 0.875em;
}
:deep(.prose pre) {
background: hsl(var(--muted));
padding: 1em;
border-radius: 0.5rem;
overflow-x: auto;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
:deep(.prose a) {
color: hsl(var(--primary));
text-decoration: underline;
}
</style>