mirror of
https://github.com/setube/ogame-vue-ts.git
synced 2026-05-12 07:55:11 +08:00
feat: 新增多语言README并优化文档结构
新增德语、俄语、韩语、繁体中文多语言README,英文与简体中文README同步优化,统一下载链接与徽章样式,完善多语言入口。提升国际化支持与文档可读性。
This commit is contained in:
376
src/components/notifications/DiplomaticNotifications.vue
Normal file
376
src/components/notifications/DiplomaticNotifications.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<Popover v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="relative">
|
||||
<ScrollText class="h-4 w-4" />
|
||||
<Badge
|
||||
v-if="unreadCount > 0"
|
||||
variant="destructive"
|
||||
class="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center text-xs"
|
||||
>
|
||||
{{ unreadCount }}
|
||||
</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-96 p-0" align="end">
|
||||
<div class="flex items-center justify-between p-4 border-b">
|
||||
<h3 class="font-semibold">{{ t('diplomacy.notifications') }}</h3>
|
||||
<Button v-if="unreadCount > 0" variant="ghost" size="sm" @click="markAllAsRead">
|
||||
{{ t('diplomacy.markAllRead') }}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea class="h-96">
|
||||
<Empty v-if="reports.length === 0" class="border-0">
|
||||
<EmptyContent>
|
||||
<ScrollText class="h-10 w-10 text-muted-foreground" />
|
||||
<EmptyDescription>{{ t('diplomacy.noReports') }}</EmptyDescription>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
<div v-else class="divide-y">
|
||||
<div
|
||||
v-for="report in reports"
|
||||
:key="report.id"
|
||||
class="p-3 hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
:class="{ 'bg-primary/5': !report.read }"
|
||||
@click="handleReportClick(report)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- 左侧:事件图标 -->
|
||||
<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">{{ getNpcName(report) }}</span>
|
||||
<Badge :variant="getStatusBadgeVariant(report.newStatus)" class="text-xs shrink-0">
|
||||
{{ getStatusText(report.newStatus) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
{{ getEventTypeText(report.eventType) }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- 右侧:好感度变化和时间 -->
|
||||
<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'"
|
||||
>
|
||||
{{ report.reputationChange >= 0 ? '+' : '' }}{{ report.reputationChange }}
|
||||
</span>
|
||||
<span class="text-[10px] text-muted-foreground">
|
||||
{{ formatRelativeTime((Date.now() - report.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 未读标记 -->
|
||||
<span v-if="!report.read" class="h-2 w-2 rounded-full bg-destructive shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div v-if="reports.length > 0" class="p-2 border-t">
|
||||
<Button variant="ghost" size="sm" class="w-full" @click="goToDiplomacy">
|
||||
{{ t('diplomacy.viewAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- 外交报告详情对话框 -->
|
||||
<Dialog :open="detailDialogOpen" @update:open="detailDialogOpen = $event">
|
||||
<DialogContent class="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<component
|
||||
v-if="selectedReport"
|
||||
:is="getEventIcon(selectedReport.eventType)"
|
||||
class="h-5 w-5"
|
||||
:class="getEventIconColor(selectedReport.eventType)"
|
||||
/>
|
||||
{{ t('diplomacy.reportDetails') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription class="sr-only">
|
||||
{{ t('diplomacy.reportDetails') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="selectedReport" class="space-y-4">
|
||||
<!-- NPC信息 -->
|
||||
<div class="flex items-center gap-3 p-4 bg-muted/50 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="font-semibold text-lg">{{ getNpcName(selectedReport) }}</h3>
|
||||
<Badge :variant="getStatusBadgeVariant(selectedReport.newStatus)">
|
||||
{{ getStatusText(selectedReport.newStatus) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ formatRelativeTime((Date.now() - selectedReport.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 事件描述 -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-semibold text-sm">{{ t('diplomacy.eventDescription') }}</h4>
|
||||
<p class="text-sm p-3 bg-muted/30 rounded-md">
|
||||
{{
|
||||
selectedReport.messageKey && selectedReport.messageParams
|
||||
? t(selectedReport.messageKey, getMessageParams(selectedReport))
|
||||
: selectedReport.message
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 关系变化 -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- 好感度变化 -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-semibold text-sm">{{ t('diplomacy.reputationChange') }}</h4>
|
||||
<div class="p-3 bg-muted/30 rounded-md">
|
||||
<div class="flex items-center justify-between text-sm mb-2">
|
||||
<span class="text-muted-foreground">{{ t('diplomacy.before') }}</span>
|
||||
<span class="font-semibold" :class="getReputationColor(selectedReport.newReputation - selectedReport.reputationChange)">
|
||||
{{ selectedReport.newReputation - selectedReport.reputationChange > 0 ? '+' : ''
|
||||
}}{{ selectedReport.newReputation - selectedReport.reputationChange }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-center text-lg font-bold my-1"
|
||||
:class="selectedReport.reputationChange >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'"
|
||||
>
|
||||
{{ selectedReport.reputationChange >= 0 ? '+' : '' }}{{ selectedReport.reputationChange }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm mt-2">
|
||||
<span class="text-muted-foreground">{{ t('diplomacy.after') }}</span>
|
||||
<span class="font-semibold" :class="getReputationColor(selectedReport.newReputation)">
|
||||
{{ selectedReport.newReputation > 0 ? '+' : '' }}{{ selectedReport.newReputation }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关系状态变化 -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-semibold text-sm">{{ t('diplomacy.statusChange') }}</h4>
|
||||
<div class="p-3 bg-muted/30 rounded-md">
|
||||
<div class="flex items-center justify-between text-sm mb-2">
|
||||
<span class="text-muted-foreground">{{ t('diplomacy.before') }}</span>
|
||||
<Badge :variant="getStatusBadgeVariant(selectedReport.oldStatus)" class="text-xs">
|
||||
{{ getStatusText(selectedReport.oldStatus) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-center my-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm mt-2">
|
||||
<span class="text-muted-foreground">{{ t('diplomacy.after') }}</span>
|
||||
<Badge :variant="getStatusBadgeVariant(selectedReport.newStatus)" class="text-xs">
|
||||
{{ getStatusText(selectedReport.newStatus) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="detailDialogOpen = false">{{ t('common.close') }}</Button>
|
||||
<Button @click="goToDiplomacyFromDialog">{{ t('diplomacy.viewDiplomacy') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { useNPCStore } from '@/stores/npcStore'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { ScrollText, Gift, Sword, Eye, Trash2, Skull } from 'lucide-vue-next'
|
||||
import { Empty, EmptyContent, EmptyDescription } from '@/components/ui/empty'
|
||||
import { RelationStatus, DiplomaticEventType } from '@/types/game'
|
||||
import type { DiplomaticReport } from '@/types/game'
|
||||
import { formatRelativeTime } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
const gameStore = useGameStore()
|
||||
const npcStore = useNPCStore()
|
||||
const { t } = useI18n()
|
||||
const isOpen = ref(false)
|
||||
const detailDialogOpen = ref(false)
|
||||
const selectedReport = ref<DiplomaticReport | null>(null)
|
||||
|
||||
const reports = computed(() => {
|
||||
return (gameStore.player.diplomaticReports || []).slice().reverse().slice(0, 20) // 最近20条
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取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
|
||||
})
|
||||
|
||||
const getEventIcon = (eventType: DiplomaticReport['eventType']) => {
|
||||
switch (eventType) {
|
||||
case DiplomaticEventType.GiftResources:
|
||||
return Gift
|
||||
case DiplomaticEventType.Attack:
|
||||
case DiplomaticEventType.AllyAttacked:
|
||||
return Sword
|
||||
case DiplomaticEventType.Spy:
|
||||
return Eye
|
||||
case DiplomaticEventType.StealDebris:
|
||||
return Trash2
|
||||
case DiplomaticEventType.DestroyPlanet:
|
||||
return Skull
|
||||
default:
|
||||
return ScrollText
|
||||
}
|
||||
}
|
||||
|
||||
const getEventIconColor = (eventType: DiplomaticReport['eventType']) => {
|
||||
switch (eventType) {
|
||||
case DiplomaticEventType.GiftResources:
|
||||
return 'text-green-500'
|
||||
case DiplomaticEventType.Attack:
|
||||
case DiplomaticEventType.DestroyPlanet:
|
||||
return 'text-red-500'
|
||||
case DiplomaticEventType.AllyAttacked:
|
||||
return 'text-orange-500'
|
||||
case DiplomaticEventType.Spy:
|
||||
return 'text-purple-500'
|
||||
case DiplomaticEventType.StealDebris:
|
||||
return 'text-yellow-500'
|
||||
default:
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
}
|
||||
|
||||
const getEventTypeText = (eventType: DiplomaticReport['eventType']) => {
|
||||
switch (eventType) {
|
||||
case DiplomaticEventType.GiftResources:
|
||||
return t('diplomacy.eventType.gift')
|
||||
case DiplomaticEventType.Attack:
|
||||
return t('diplomacy.eventType.attack')
|
||||
case DiplomaticEventType.AllyAttacked:
|
||||
return t('diplomacy.eventType.allyAttacked')
|
||||
case DiplomaticEventType.Spy:
|
||||
return t('diplomacy.eventType.spy')
|
||||
case DiplomaticEventType.StealDebris:
|
||||
return t('diplomacy.eventType.stealDebris')
|
||||
case DiplomaticEventType.DestroyPlanet:
|
||||
return t('diplomacy.eventType.destroyPlanet')
|
||||
default:
|
||||
return t('diplomacy.eventType.unknown')
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadgeVariant = (status: RelationStatus) => {
|
||||
switch (status) {
|
||||
case RelationStatus.Hostile:
|
||||
return 'destructive'
|
||||
case RelationStatus.Friendly:
|
||||
return 'default'
|
||||
case RelationStatus.Neutral:
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: RelationStatus) => {
|
||||
switch (status) {
|
||||
case RelationStatus.Hostile:
|
||||
return t('diplomacy.status.hostile')
|
||||
case RelationStatus.Friendly:
|
||||
return t('diplomacy.status.friendly')
|
||||
case RelationStatus.Neutral:
|
||||
default:
|
||||
return t('diplomacy.status.neutral')
|
||||
}
|
||||
}
|
||||
|
||||
const getReputationColor = (reputation: number | null) => {
|
||||
if (reputation === null) return 'text-muted-foreground'
|
||||
if (reputation >= 20) return 'text-green-600 dark:text-green-400'
|
||||
if (reputation <= -20) return 'text-red-600 dark:text-red-400'
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
|
||||
const handleReportClick = (report: DiplomaticReport) => {
|
||||
// 标记为已读
|
||||
report.read = true
|
||||
// 设置选中的报告
|
||||
selectedReport.value = report
|
||||
// 关闭通知面板
|
||||
isOpen.value = true
|
||||
// 打开对话框
|
||||
detailDialogOpen.value = true
|
||||
}
|
||||
|
||||
const markAllAsRead = () => {
|
||||
gameStore.player.diplomaticReports?.forEach(report => {
|
||||
report.read = true
|
||||
})
|
||||
}
|
||||
|
||||
const goToDiplomacy = () => {
|
||||
isOpen.value = false
|
||||
router.push('/diplomacy')
|
||||
}
|
||||
|
||||
const goToDiplomacyFromDialog = () => {
|
||||
const npcId = selectedReport.value?.npcId
|
||||
detailDialogOpen.value = false
|
||||
router.push(npcId ? `/diplomacy?npcId=${npcId}` : '/diplomacy')
|
||||
}
|
||||
</script>
|
||||
339
src/components/notifications/EnemyAlertNotifications.vue
Normal file
339
src/components/notifications/EnemyAlertNotifications.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<Popover v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="relative">
|
||||
<Siren class="h-4 w-4" />
|
||||
<Badge
|
||||
v-if="activeAlerts.length > 0"
|
||||
variant="destructive"
|
||||
class="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center text-xs animate-pulse"
|
||||
>
|
||||
{{ activeAlerts.length }}
|
||||
</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('enemyAlert.title') }}</h3>
|
||||
<Button v-if="activeAlerts.length > 0" variant="ghost" size="sm" @click="markAllAsRead">
|
||||
{{ t('enemyAlert.markAllRead') }}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea class="h-96">
|
||||
<Empty v-if="activeAlerts.length === 0" class="border-0">
|
||||
<EmptyContent>
|
||||
<Shield class="h-10 w-10 text-muted-foreground" />
|
||||
<EmptyDescription>{{ t('enemyAlert.noAlerts') }}</EmptyDescription>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
<div v-else class="divide-y">
|
||||
<div
|
||||
v-for="alert in activeAlerts"
|
||||
:key="alert.id"
|
||||
class="p-3 hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
:class="{ 'bg-destructive/10': !alert.read }"
|
||||
@click="handleAlertClick(alert)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- 左侧:任务图标 -->
|
||||
<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">{{ getNpcName(alert) }}</span>
|
||||
<Badge :variant="getMissionBadgeVariant(alert.missionType)" class="text-xs shrink-0">
|
||||
{{ getMissionTypeText(alert.missionType) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
{{ alert.targetPlanetName }} · {{ t('enemyAlert.fleetSize') }}: {{ alert.fleetSize }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- 右侧:倒计时 -->
|
||||
<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 shrink-0 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div v-if="activeAlerts.length > 0" class="p-2 border-t">
|
||||
<Button variant="ghost" size="sm" class="w-full" @click="goToFleet">
|
||||
{{ t('enemyAlert.viewFleet') }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<!-- 警报详情对话框 -->
|
||||
<Dialog :open="detailDialogOpen" @update:open="detailDialogOpen = $event">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<component
|
||||
v-if="selectedAlert"
|
||||
:is="getMissionIcon(selectedAlert.missionType)"
|
||||
class="h-5 w-5"
|
||||
:class="getMissionIconColor(selectedAlert.missionType)"
|
||||
/>
|
||||
{{ t('enemyAlert.alertDetails') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription class="sr-only">
|
||||
{{ t('enemyAlert.alertDetails') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="selectedAlert" class="space-y-4">
|
||||
<!-- 敌方信息 -->
|
||||
<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">{{ getNpcName(selectedAlert) }}</h3>
|
||||
<Badge :variant="getMissionBadgeVariant(selectedAlert.missionType)">
|
||||
{{ getMissionTypeText(selectedAlert.missionType) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('enemyAlert.fleetSize') }}: {{ selectedAlert.fleetSize }} {{ t('enemyAlert.ships') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 目标信息 -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-semibold text-sm">{{ t('enemyAlert.targetInfo') }}</h4>
|
||||
<div class="p-3 bg-muted/30 rounded-md flex items-center gap-2">
|
||||
<Globe class="h-4 w-4 text-blue-500" />
|
||||
<span class="font-medium">{{ selectedAlert.targetPlanetName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 到达时间 -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-semibold text-sm">{{ t('enemyAlert.arrivalTime') }}</h4>
|
||||
<div class="p-3 bg-muted/30 rounded-md">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-foreground">{{ t('enemyAlert.countdown') }}</span>
|
||||
<span class="font-bold text-lg" :class="getRemainingTimeColor(selectedAlert)">
|
||||
{{ formatRemainingTime(selectedAlert) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{{ formatDate(selectedAlert.arrivalTime) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 警告提示 -->
|
||||
<div class="p-3 bg-destructive/10 rounded-md border border-destructive/20">
|
||||
<p class="text-sm text-destructive dark:text-red-400">
|
||||
{{ getMissionWarningText(selectedAlert.missionType) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="detailDialogOpen = false">{{ t('common.close') }}</Button>
|
||||
<Button @click="goToMessagesFromDialog">{{ t('enemyAlert.viewMessages') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Empty, EmptyContent, EmptyDescription } from '@/components/ui/empty'
|
||||
import { Siren, Eye, Sword, Shield, Globe, Recycle } from 'lucide-vue-next'
|
||||
import { MissionType } from '@/types/game'
|
||||
import type { IncomingFleetAlert } from '@/types/game'
|
||||
import { formatDate, formatTime } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
const gameStore = useGameStore()
|
||||
const npcStore = useNPCStore()
|
||||
const { t } = useI18n()
|
||||
const isOpen = ref(false)
|
||||
const detailDialogOpen = ref(false)
|
||||
const selectedAlert = ref<IncomingFleetAlert | null>(null)
|
||||
const currentTime = ref(Date.now())
|
||||
let timeInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 启动计时器,用于实时更新倒计时
|
||||
onMounted(() => {
|
||||
timeInterval = setInterval(() => {
|
||||
currentTime.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeInterval) {
|
||||
clearInterval(timeInterval)
|
||||
timeInterval = null
|
||||
}
|
||||
})
|
||||
|
||||
// 获取活跃的警报(未到达的)
|
||||
const activeAlerts = computed(() => {
|
||||
const now = currentTime.value
|
||||
return (gameStore.player.incomingFleetAlerts || [])
|
||||
.filter(alert => alert.arrivalTime > now)
|
||||
.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) {
|
||||
case MissionType.Spy:
|
||||
return Eye
|
||||
case MissionType.Attack:
|
||||
return Sword
|
||||
case MissionType.Recycle:
|
||||
return Recycle
|
||||
default:
|
||||
return Siren
|
||||
}
|
||||
}
|
||||
|
||||
// 获取任务类型图标颜色
|
||||
const getMissionIconColor = (missionType: MissionType) => {
|
||||
switch (missionType) {
|
||||
case MissionType.Spy:
|
||||
return 'text-purple-500'
|
||||
case MissionType.Attack:
|
||||
return 'text-red-500'
|
||||
case MissionType.Recycle:
|
||||
return 'text-amber-500'
|
||||
default:
|
||||
return 'text-yellow-500'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取任务类型Badge样式
|
||||
const getMissionBadgeVariant = (missionType: MissionType): 'destructive' | 'secondary' => {
|
||||
return missionType === MissionType.Attack ? 'destructive' : 'secondary'
|
||||
}
|
||||
|
||||
// 获取任务类型文本
|
||||
const getMissionTypeText = (missionType: MissionType) => {
|
||||
switch (missionType) {
|
||||
case MissionType.Spy:
|
||||
return t('enemyAlert.missionType.spy')
|
||||
case MissionType.Attack:
|
||||
return t('enemyAlert.missionType.attack')
|
||||
case MissionType.Recycle:
|
||||
return t('enemyAlert.missionType.recycle')
|
||||
default:
|
||||
return t('enemyAlert.missionType.unknown')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取任务警告文本
|
||||
const getMissionWarningText = (missionType: MissionType) => {
|
||||
switch (missionType) {
|
||||
case MissionType.Spy:
|
||||
return t('enemyAlert.warning.spy')
|
||||
case MissionType.Attack:
|
||||
return t('enemyAlert.warning.attack')
|
||||
case MissionType.Recycle:
|
||||
return t('enemyAlert.warning.recycle')
|
||||
default:
|
||||
return t('enemyAlert.warning.unknown')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化剩余时间
|
||||
const formatRemainingTime = (alert: IncomingFleetAlert) => {
|
||||
const remaining = Math.max(0, Math.floor((alert.arrivalTime - currentTime.value) / 1000))
|
||||
if (remaining <= 0) {
|
||||
return t('enemyAlert.arrived')
|
||||
}
|
||||
return formatTime(remaining)
|
||||
}
|
||||
|
||||
// 获取剩余时间颜色
|
||||
const getRemainingTimeColor = (alert: IncomingFleetAlert) => {
|
||||
const remaining = alert.arrivalTime - currentTime.value
|
||||
if (remaining <= 0) return 'text-red-600 dark:text-red-400 font-bold' // 已到达
|
||||
if (remaining < 60000) return 'text-red-600 dark:text-red-400' // < 1分钟
|
||||
if (remaining < 300000) return 'text-orange-600 dark:text-orange-400' // < 5分钟
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
|
||||
// 处理警报点击
|
||||
const handleAlertClick = (alert: IncomingFleetAlert) => {
|
||||
alert.read = true
|
||||
selectedAlert.value = alert
|
||||
isOpen.value = true
|
||||
// 打开对话框
|
||||
detailDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 标记所有为已读
|
||||
const markAllAsRead = () => {
|
||||
gameStore.player.incomingFleetAlerts?.forEach(alert => {
|
||||
alert.read = true
|
||||
})
|
||||
}
|
||||
|
||||
// 跳转到舰队页面
|
||||
const goToFleet = () => {
|
||||
isOpen.value = false
|
||||
router.push('/fleet')
|
||||
}
|
||||
|
||||
// 从对话框跳转到消息页面
|
||||
const goToMessagesFromDialog = () => {
|
||||
detailDialogOpen.value = false
|
||||
router.push('/messages')
|
||||
}
|
||||
|
||||
// 打开弹窗(供外部调用)
|
||||
const open = () => {
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
open
|
||||
})
|
||||
</script>
|
||||
83
src/components/notifications/HintToast.vue
Normal file
83
src/components/notifications/HintToast.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="-translate-y-4 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="-translate-y-4 opacity-0"
|
||||
>
|
||||
<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 shrink-0" />
|
||||
<h4 class="font-medium text-sm">{{ t(currentHint.titleKey) }}</h4>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<p class="text-sm text-muted-foreground mb-3 line-clamp-3">{{ t(currentHint.messageKey) }}</p>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center justify-end">
|
||||
<Button size="sm" class="text-xs h-7" @click="handleDismiss">
|
||||
{{ t('hints.dontShowAgain') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useHints } from '@/composables/useHints'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Home,
|
||||
Building,
|
||||
FlaskConical,
|
||||
Rocket,
|
||||
Plane,
|
||||
Globe,
|
||||
Handshake,
|
||||
Mail,
|
||||
Shield,
|
||||
Lightbulb,
|
||||
Users,
|
||||
Swords,
|
||||
Settings,
|
||||
Wand2
|
||||
} from 'lucide-vue-next'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { currentHint, isHintVisible, dismissHint } = useHints()
|
||||
|
||||
// 图标名称到组件的映射
|
||||
const iconMap: Record<string, Component> = {
|
||||
home: Home,
|
||||
building: Building,
|
||||
flask: FlaskConical,
|
||||
rocket: Rocket,
|
||||
plane: Plane,
|
||||
globe: Globe,
|
||||
handshake: Handshake,
|
||||
mail: Mail,
|
||||
shield: Shield,
|
||||
users: Users,
|
||||
swords: Swords,
|
||||
settings: Settings,
|
||||
wand: Wand2
|
||||
}
|
||||
|
||||
const getIcon = (iconName?: string) => {
|
||||
if (!iconName) return Lightbulb
|
||||
return iconMap[iconName] || Lightbulb
|
||||
}
|
||||
|
||||
// 不再显示 - 永久关闭
|
||||
const handleDismiss = () => {
|
||||
dismissHint(true)
|
||||
}
|
||||
</script>
|
||||
114
src/components/notifications/IncomingFleetAlerts.vue
Normal file
114
src/components/notifications/IncomingFleetAlerts.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div v-if="activeAlerts.length > 0" class="bg-destructive/10 border-b border-destructive/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">
|
||||
<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() }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('enemyAlert.countdown') }}: {{ formatTimeRemaining(nearestAlert?.arrivalTime || 0) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查看按钮 -->
|
||||
<Button @click="openAlertPanel" variant="outline" size="sm" class="shrink-0">
|
||||
{{ t('common.view') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle } from 'lucide-vue-next'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import { MissionType } from '@/types/game'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'openPanel'): void
|
||||
}>()
|
||||
|
||||
const gameStore = useGameStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 强制更新倒计时
|
||||
const now = ref(Date.now())
|
||||
let updateInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
updateInterval = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (updateInterval) clearInterval(updateInterval)
|
||||
})
|
||||
|
||||
// 获取活跃的警报(未到达的)
|
||||
const activeAlerts = computed(() => {
|
||||
return (gameStore.player.incomingFleetAlerts || [])
|
||||
.filter(alert => alert.arrivalTime > now.value)
|
||||
.sort((a, b) => a.arrivalTime - b.arrivalTime)
|
||||
})
|
||||
|
||||
// 最近的警报
|
||||
const nearestAlert = computed(() => activeAlerts.value[0] || null)
|
||||
|
||||
// 统计各类型警报数量
|
||||
const alertCounts = computed(() => {
|
||||
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++
|
||||
}
|
||||
})
|
||||
return counts
|
||||
})
|
||||
|
||||
// 生成警报汇总文本
|
||||
const getAlertSummary = (): string => {
|
||||
const parts: string[] = []
|
||||
if (alertCounts.value.attack > 0) {
|
||||
parts.push(`${alertCounts.value.attack} ${t('enemyAlert.missionType.attack')}`)
|
||||
}
|
||||
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')}`)
|
||||
}
|
||||
return t('alerts.incomingFleets', { count: activeAlerts.value.length }) + ': ' + parts.join(', ')
|
||||
}
|
||||
|
||||
const formatTimeRemaining = (arrivalTime: number): string => {
|
||||
const remaining = Math.max(0, arrivalTime - now.value)
|
||||
const seconds = Math.floor((remaining / 1000) % 60)
|
||||
const minutes = Math.floor((remaining / (1000 * 60)) % 60)
|
||||
const hours = Math.floor(remaining / (1000 * 60 * 60))
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const openAlertPanel = () => {
|
||||
emit('openPanel')
|
||||
}
|
||||
</script>
|
||||
72
src/components/notifications/LowEnergyWarning.vue
Normal file
72
src/components/notifications/LowEnergyWarning.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div v-if="showWarning" class="bg-destructive/10 border-b border-destructive/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">
|
||||
<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') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ detailMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 建造电站按钮 -->
|
||||
<Button @click="goToBuildSolarPlant" variant="outline" size="sm" class="shrink-0">
|
||||
{{ t('energy.buildSolarPlant') }}
|
||||
</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 { Zap } from 'lucide-vue-next'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import * as resourceLogic from '@/logic/resourceLogic'
|
||||
import * as officerLogic from '@/logic/officerLogic'
|
||||
|
||||
const gameStore = useGameStore()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 获取当前星球
|
||||
const planet = computed(() => gameStore.currentPlanet)
|
||||
|
||||
// 计算能量产量
|
||||
const energyProduction = computed(() => {
|
||||
if (!planet.value) return 0
|
||||
const now = Date.now()
|
||||
const bonuses = officerLogic.calculateActiveBonuses(gameStore.player.officers, now)
|
||||
return resourceLogic.calculateEnergyProduction(planet.value, { energyProductionBonus: bonuses.energyProductionBonus })
|
||||
})
|
||||
|
||||
// 计算能量消耗
|
||||
const energyConsumption = computed(() => {
|
||||
if (!planet.value) return 0
|
||||
return resourceLogic.calculateEnergyConsumption(planet.value)
|
||||
})
|
||||
|
||||
// 是否显示警告(电力产量 < 消耗)
|
||||
const showWarning = computed(() => {
|
||||
if (!planet.value) return false
|
||||
return energyProduction.value < energyConsumption.value
|
||||
})
|
||||
|
||||
// 详细消息
|
||||
const detailMessage = computed(() => {
|
||||
const deficit = Math.ceil(energyConsumption.value - energyProduction.value)
|
||||
return t('energy.deficitDetail', { deficit: deficit.toString() })
|
||||
})
|
||||
|
||||
// 跳转到建筑页面建造太阳能电站
|
||||
const goToBuildSolarPlant = () => {
|
||||
router.push('/buildings')
|
||||
}
|
||||
</script>
|
||||
119
src/components/notifications/OreDepositWarning.vue
Normal file
119
src/components/notifications/OreDepositWarning.vue
Normal 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>
|
||||
317
src/components/notifications/QueueNotifications.vue
Normal file
317
src/components/notifications/QueueNotifications.vue
Normal 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>
|
||||
Reference in New Issue
Block a user